diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd708786..96bab93a3 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 - Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503) - Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516) - Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495) +- Connection groups in Switch Connection, with `Cmd`-click to open a saved connection in a new window. (#1311) ### Changed diff --git a/TablePro/Core/Services/Infrastructure/TabRouter.swift b/TablePro/Core/Services/Infrastructure/TabRouter.swift index 88fe65c19..608013daa 100644 --- a/TablePro/Core/Services/Infrastructure/TabRouter.swift +++ b/TablePro/Core/Services/Infrastructure/TabRouter.swift @@ -99,6 +99,38 @@ internal final class TabRouter { try await openConnection(id: connection.id, transientConnection: connection) } + /// Open a saved connection in a window of its own. One some window already hosts takes the + /// ordinary route instead, which selects it where it already is. + /// + /// The host is checked here rather than by the caller. A caller decides on a modifier key and + /// the work runs a main-actor job later, and anything else that opens a connection in between, + /// the MCP tool among them, would leave that answer stale and two workspaces restoring the same + /// tabs. Nothing is awaited between the question and the window. + /// + /// No pre-connect script prompt here, matching the window-opening half of `openConnection`. A + /// window whose connection carries a script does not auto-connect at all: it waits in its + /// not-connected state, where Connect asks. Asking first would put the same question twice and + /// the first answer would change nothing. + internal func openConnectionPreferringNewWindow(id: UUID) async throws { + guard WindowManager.shared.window(for: id) == nil else { + try await openConnection(id: id) + return + } + guard let connection = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) else { + throw TabRouterError.connectionNotFound(id) + } + + let payload = EditorTabPayload(connectionId: connection.id, intent: .restoreOrDefault) + WindowManager.shared.openInNewWindow( + payload: payload, + activate: true, + autoConnect: true, + joinsTabGroup: false + ) + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + WindowOpener.shared.closeWelcome() + } + private func openConnection(id: UUID, transientConnection: DatabaseConnection? = nil) async throws { let connection: DatabaseConnection if let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == id }) { diff --git a/TablePro/Core/Services/Infrastructure/WindowManager.swift b/TablePro/Core/Services/Infrastructure/WindowManager.swift index 12a8564b3..91810ec2c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowManager.swift +++ b/TablePro/Core/Services/Infrastructure/WindowManager.swift @@ -254,7 +254,18 @@ internal final class WindowManager { return true } - private func openInNewWindow(payload: EditorTabPayload, activate: Bool, autoConnect: Bool) { + /// Forced, rather than the adoption `openTab` prefers. + /// + /// `joinsTabGroup` is the difference between "not in the window it would have been adopted + /// into" and "in a window of its own". Left alone, this joins the existing group, so a caller + /// that wants two connections side by side has to say so: without it the new window arrives as + /// a native tab of the very window it was meant to sit beside. + internal func openInNewWindow( + payload: EditorTabPayload, + activate: Bool, + autoConnect: Bool, + joinsTabGroup: Bool = true + ) { let t0 = Date() Self.lifecycleLogger.info( "[open] WindowManager.openTab start payloadId=\(payload.id, privacy: .public) connId=\(payload.connectionId, privacy: .public) intent=\(String(describing: payload.intent), privacy: .public) skipAutoExecute=\(payload.skipAutoExecute) activate=\(activate)" @@ -282,8 +293,9 @@ internal final class WindowManager { // orderFront before addTabbedWindow avoids a synchronous full-tree // SwiftUI layout pass that adds 700-900ms per open. let tabbingId = window.tabbingIdentifier + let sibling = joinsTabGroup ? findSibling(tabbingIdentifier: tabbingId, excluding: window) : nil - if let sibling = findSibling(tabbingIdentifier: tabbingId, excluding: window) { + if let sibling { let target = sibling.tabbedWindows?.last ?? sibling target.addTabbedWindow(window, ordered: .above) if activate { @@ -293,12 +305,22 @@ internal final class WindowManager { "[open] WindowManager joined existing tab group payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) } else { + /// The system preference can tab a window on its own, without anyone asking AppKit to, + /// so a window asked to stand apart refuses for the moment it is placed and allows it + /// again straight after: standing apart now does not cost it the right to be merged by + /// hand later. + if !joinsTabGroup { + window.tabbingMode = .disallowed + } if activate { window.makeKeyAndOrderFront(nil) AppActivationPolicyController.shared.activate(ignoringOtherApps: true) } else { window.orderFront(nil) } + if !joinsTabGroup { + window.tabbingMode = .automatic + } Self.lifecycleLogger.info( "[open] WindowManager standalone window payloadId=\(payload.id, privacy: .public) tabbingId=\(tabbingId, privacy: .public)" ) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 3d6df2300..c53351a5f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4528,6 +4528,40 @@ } } }, + "UNGROUPED" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GRUPSUZ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KHÔNG THUỘC NHÓM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未分组" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未分組" + } + } + } + }, "%@x" : { "localizations" : { "ko" : { diff --git a/TablePro/Views/Shared/FieldDrivenList.swift b/TablePro/Views/Shared/FieldDrivenList.swift index b91a368a7..001393e78 100644 --- a/TablePro/Views/Shared/FieldDrivenList.swift +++ b/TablePro/Views/Shared/FieldDrivenList.swift @@ -9,11 +9,15 @@ import SwiftUI internal struct FieldDrivenListSection: Identifiable { internal let id: String internal let title: String? + /// Drawn as a dot beside the title, for a section that stands for something the user gave a + /// colour to. Nil leaves the header as a plain label. + internal let accentColor: NSColor? internal let items: [Item] - internal init(id: String, title: String? = nil, items: [Item]) { + internal init(id: String, title: String? = nil, accentColor: NSColor? = nil, items: [Item]) { self.id = id self.title = title + self.accentColor = accentColor self.items = items } } @@ -213,8 +217,8 @@ internal struct FieldDrivenList: NSViewRepresenta internal func tableView(_ tableView: NSTableView, viewFor column: NSTableColumn?, row: Int) -> NSView? { guard row < entries.count else { return nil } switch entries[row] { - case .header(_, let title): - return FieldDrivenHeaderView.make(title: title) + case .header(_, let title, let accentColor): + return FieldDrivenHeaderView.make(title: title, accentColor: accentColor) case .item(let item): let cell = tableView.makeView( withIdentifier: FieldDrivenCellView.reuseIdentifier, @@ -445,7 +449,9 @@ internal final class FieldDrivenCellView: NSTableCellView { } internal enum FieldDrivenHeaderView { - internal static func make(title: String) -> NSView { + private static let dotSize: CGFloat = 6 + + internal static func make(title: String, accentColor: NSColor? = nil) -> NSView { let label = NSTextField(labelWithString: title) label.font = .preferredFont(forTextStyle: .caption1) label.textColor = .secondaryLabelColor @@ -454,10 +460,46 @@ internal enum FieldDrivenHeaderView { let container = NSView() container.addSubview(label) NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor), label.centerYAnchor.constraint(equalTo: container.centerYAnchor), ]) + + guard let accentColor else { + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4).isActive = true + return container + } + + let dot = ColorDotView(color: accentColor) + dot.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(dot) + NSLayoutConstraint.activate([ + dot.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + dot.centerYAnchor.constraint(equalTo: label.centerYAnchor), + dot.widthAnchor.constraint(equalToConstant: dotSize), + dot.heightAnchor.constraint(equalToConstant: dotSize), + label.leadingAnchor.constraint(equalTo: dot.trailingAnchor, constant: 5), + ]) return container } } + +/// A dot that repaints itself when the appearance changes, because a dynamic system colour +/// resolved once into a layer stays at the appearance it was resolved in. +private final class ColorDotView: NSView { + private let color: NSColor + + init(color: NSColor) { + self.color = color + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("ColorDotView does not support NSCoder init") + } + + override func draw(_ dirtyRect: NSRect) { + color.setFill() + NSBezierPath(ovalIn: bounds).fill() + } +} diff --git a/TablePro/Views/Shared/FieldDrivenListEntry.swift b/TablePro/Views/Shared/FieldDrivenListEntry.swift index fd65c1067..242a23663 100644 --- a/TablePro/Views/Shared/FieldDrivenListEntry.swift +++ b/TablePro/Views/Shared/FieldDrivenListEntry.swift @@ -3,12 +3,12 @@ // TablePro // -import Foundation +import AppKit /// One row of a `FieldDrivenList`, after sections have been flattened into the single index space /// an `NSTableView` works in. internal enum FieldDrivenListEntry where Item.ID: Hashable { - case header(id: String, title: String) + case header(id: String, title: String, accentColor: NSColor?) case item(Item) internal var isHeader: Bool { @@ -23,10 +23,16 @@ internal enum FieldDrivenListEntry where Item.ID: Hashable { /// Identity, not content. A refilter that produces the same rows in the same order reloads /// nothing, which keeps the hosted SwiftUI views and their state alive. + /// + /// A header is the exception: only item rows are refreshed in place, so a header identified by + /// its section id alone would keep a group's old name and colour on screen after a rename + /// arrives from another device. Its drawn content is part of what identifies it. internal var identity: AnyHashable { switch self { - case .header(let id, _): return AnyHashable("header:" + id) - case .item(let item): return AnyHashable(item.id) + case .header(let id, let title, let accentColor): + return AnyHashable(HeaderIdentity(id: id, title: title, accentColor: accentColor)) + case .item(let item): + return AnyHashable(item.id) } } @@ -37,7 +43,13 @@ internal enum FieldDrivenListEntry where Item.ID: Hashable { guard !section.items.isEmpty else { return [] } let rows = section.items.map { FieldDrivenListEntry.item($0) } guard let title = section.title else { return rows } - return [.header(id: section.id, title: title)] + rows + return [.header(id: section.id, title: title, accentColor: section.accentColor)] + rows } } } + +private struct HeaderIdentity: Hashable { + let id: String + let title: String + let accentColor: NSColor? +} diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index a864108b6..23bc61b21 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -4,6 +4,7 @@ // import AppKit +import Combine import SwiftUI import TableProPluginKit @@ -43,6 +44,8 @@ struct ConnectionSwitcherPopover: View { let dismiss: () -> Void @State private var savedConnections: [DatabaseConnection] = [] + @State private var groups: [ConnectionGroup] = [] + @State private var hostedWithoutSession: [DatabaseConnection] = [] @State private var selectedConnectionId: UUID? @State private var searchText = "" @@ -62,20 +65,54 @@ struct ConnectionSwitcherPopover: View { Array(activeSessions.values).sorted { $0.lastActiveAt > $1.lastActiveAt } } + /// Open means a window hosts it, which is not the same as a session existing for it. A + /// workspace outlives its session, so a connect that failed, one the user cancelled and an + /// explicit disconnect all leave a connection open with nothing in `activeSessions`. Listing + /// those under a group would put a connection the window is already showing in the library + /// half, where Command-click promises a window it will not get. + /// + /// Held in state rather than read during `body`, because `WindowManager` is not observable and + /// nothing would re-evaluate this when a window opens or closes. + private var openEntries: [ConnectionSwitcherEntry] { + var entries = sortedSessions.map { + ConnectionSwitcherEntry( + id: $0.id, + connection: $0.connection, + isActive: $0.id == currentSessionId, + isConnected: $0.status.isConnected + ) + } + entries += hostedWithoutSession.map { + ConnectionSwitcherEntry(id: $0.id, connection: $0, isActive: false, isConnected: false) + } + return entries + } + + private var openConnectionIds: Set { + Set(activeSessions.keys).union(hostedWithoutSession.map(\.id)) + } + private var inactiveSaved: [DatabaseConnection] { - savedConnections.filter { activeSessions[$0.id] == nil } + let open = openConnectionIds + return savedConnections.filter { !open.contains($0.id) } } - private var filteredSessions: [ConnectionSession] { - sortedSessions.filter { ConnectionSwitcherFilter.matches($0.connection, query: searchText) } + private var filteredOpen: [ConnectionSwitcherEntry] { + openEntries.filter { ConnectionSwitcherFilter.matches($0.connection, query: searchText) } } private var filteredSaved: [DatabaseConnection] { inactiveSaved.filter { ConnectionSwitcherFilter.matches($0, query: searchText) } } + /// Read off the sections rather than assembled a second time, so the order the arrow keys walk + /// is the order the list draws by construction. private var orderedIds: [UUID] { - filteredSessions.map(\.id) + filteredSaved.map(\.id) + sections.flatMap { $0.items.map(\.id) } + } + + private var isFiltering: Bool { + !searchText.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { @@ -92,15 +129,23 @@ struct ConnectionSwitcherPopover: View { } .frame(width: Self.contentSize.width, height: Self.contentSize.height) .onAppear { - savedConnections = ConnectionStorage.shared.loadConnections() + reload() if selectedConnectionId == nil { selectedConnectionId = currentSessionId ?? orderedIds.first } } + /// The subject itself, not a `receive(on:)` wrapper: that builds a new publisher on every + /// body pass, and every sender is already on the main actor. + .onReceive(AppEvents.shared.connectionUpdated) { _ in + reload() + settleSelection() + } + .onReceive(AppEvents.shared.connectionWindowsChanged) { _ in + reload() + settleSelection() + } .onChange(of: searchText) { _, _ in - let ids = orderedIds - if let id = selectedConnectionId, ids.contains(id) { return } - selectedConnectionId = ids.first + settleSelection() } } @@ -127,27 +172,12 @@ struct ConnectionSwitcherPopover: View { } private var sections: [FieldDrivenListSection] { - [ - FieldDrivenListSection( - id: "active", - title: String(localized: "ACTIVE CONNECTIONS"), - items: filteredSessions.map { - ConnectionSwitcherEntry( - id: $0.id, - connection: $0.connection, - isActive: $0.id == currentSessionId, - isConnected: $0.status.isConnected - ) - } - ), - FieldDrivenListSection( - id: "saved", - title: String(localized: "SAVED CONNECTIONS"), - items: filteredSaved.map { - ConnectionSwitcherEntry(id: $0.id, connection: $0, isActive: false, isConnected: false) - } - ), - ] + ConnectionSwitcherSections.build( + active: filteredOpen, + saved: filteredSaved, + groups: groups, + isFiltering: isFiltering + ) } /// The search field keeps focus for the whole flow, so the list is a presentation of that @@ -273,6 +303,24 @@ struct ConnectionSwitcherPopover: View { // MARK: - Selection + private func reload() { + let saved = ConnectionStorage.shared.loadConnections() + savedConnections = saved + groups = GroupStorage.shared.loadGroups() + + hostedWithoutSession = ConnectionSwitcherSections.hostedWithoutSession( + workspaces: WindowManager.shared.hostedWorkspaces().map { ($0.connectionId, $0.connection) }, + sessionIds: Set(DatabaseManager.shared.activeSessions.keys), + saved: saved + ) + } + + private func settleSelection() { + let ids = orderedIds + if let id = selectedConnectionId, ids.contains(id) { return } + selectedConnectionId = ids.first + } + private func moveSelection(by offset: Int) { if let next = ConnectionSwitcherSelection.moved(in: orderedIds, from: selectedConnectionId, by: offset) { selectedConnectionId = next @@ -284,11 +332,22 @@ struct ConnectionSwitcherPopover: View { activate(connectionId: id) } + /// Command-click opens a saved connection in a window of its own, the modifier Finder and + /// Safari use for the same intent. A connection already open is switched to either way: moving + /// one between windows belongs to the connections strip, which owns that arrangement. + /// + /// Whether a window already hosts it is settled by the router, next to the window it builds, + /// rather than here where the answer would be a main-actor job old by the time it is used. private func activate(connectionId: UUID) { + let opensNewWindow = NSApp.currentEvent?.modifierFlags.contains(.command) == true dismiss() Task { do { - try await TabRouter.shared.route(.openConnection(connectionId)) + if opensNewWindow { + try await TabRouter.shared.openConnectionPreferringNewWindow(id: connectionId) + } else { + try await TabRouter.shared.route(.openConnection(connectionId)) + } } catch { await MainActor.run { AlertHelper.showErrorSheet( @@ -309,3 +368,130 @@ struct ConnectionSwitcherPopover: View { return "\(connection.host)\(port)/\(connection.database)" } } + +// MARK: - Sections + +internal enum ConnectionSwitcherSections { + /// Open connections keep their own section at the top: they are the working set, and burying + /// one inside its group would put the two connections a user switches between furthest apart. + /// Everything below is the library, and that is where the group hierarchy belongs. + /// + /// A filter collapses the groups back into one list. A search is a lookup rather than a browse, + /// and a connection matching in each of eight groups would otherwise be eight one-row sections. + internal static func build( + active: [ConnectionSwitcherEntry], + saved: [DatabaseConnection], + groups: [ConnectionGroup], + isFiltering: Bool + ) -> [FieldDrivenListSection] { + var sections = [ + FieldDrivenListSection( + id: "active", + title: String(localized: "ACTIVE CONNECTIONS"), + items: active + ), + ] + + guard !isFiltering else { + sections.append( + FieldDrivenListSection( + id: "saved", + title: String(localized: "SAVED CONNECTIONS"), + items: saved.map(entry) + ) + ) + return sections + } + + var ungrouped: [DatabaseConnection] = [] + let sectionsBeforeGroups = sections.count + append( + buildGroupTreeIndexed(groups: groups, connections: saved), + path: [], + into: §ions, + ungrouped: &ungrouped + ) + + guard !ungrouped.isEmpty else { return sections } + + /// "Ungrouped" only means anything beside a group. With no groups on screen there is + /// nothing for it to contrast with, and the list is just the saved connections. + let hasGroups = sections.count > sectionsBeforeGroups + sections.append( + FieldDrivenListSection( + id: "ungrouped", + title: hasGroups ? String(localized: "UNGROUPED") : String(localized: "SAVED CONNECTIONS"), + items: ungrouped.map(entry) + ) + ) + return sections + } + + /// The connections a window still holds with no session behind them, deduplicated and named. + /// + /// One connection can be hosted twice once it has been moved into a window of its own, and a + /// workspace that never got as far as a session has no record of its own, so the saved list + /// answers for it. + internal static func hostedWithoutSession( + workspaces: [(connectionId: UUID, connection: DatabaseConnection?)], + sessionIds: Set, + saved: [DatabaseConnection] + ) -> [DatabaseConnection] { + var seen: Set = [] + return workspaces.compactMap { workspace in + guard !sessionIds.contains(workspace.connectionId), + seen.insert(workspace.connectionId).inserted else { return nil } + return workspace.connection ?? saved.first { $0.id == workspace.connectionId } + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + private static func entry(for connection: DatabaseConnection) -> ConnectionSwitcherEntry { + ConnectionSwitcherEntry(id: connection.id, connection: connection, isActive: false, isConnected: false) + } + + /// One section per group, in the order the connection list shows them, with a nested group + /// naming its whole path. The header carries the group's own colour, and the connections that + /// belong to no group come last, which is where the tree puts them too. + private static func append( + _ nodes: [ConnectionGroupTreeNode], + path: [String], + into sections: inout [FieldDrivenListSection], + ungrouped: inout [DatabaseConnection] + ) { + for node in nodes { + switch node { + case .connection(let connection): + ungrouped.append(connection) + case .group(let group, let children): + var connections: [DatabaseConnection] = [] + var subgroups: [ConnectionGroupTreeNode] = [] + for child in children { + if case .connection(let connection) = child { + connections.append(connection) + } else { + subgroups.append(child) + } + } + + /// A group with nothing under it draws no header, so it contributes no section + /// either. Leaving an empty one in made the list claim a hierarchy it was not + /// showing, and the loose connections then read as "ungrouped" against nothing. + /// A parent whose own connections are elsewhere still names itself through its + /// children's path. + let names = path + [group.name] + if !connections.isEmpty { + sections.append( + FieldDrivenListSection( + id: "group-\(group.id)", + title: names.joined(separator: " / ").localizedUppercase, + accentColor: group.color.indicatorColor.map(NSColor.init), + items: connections.map(entry) + ) + ) + } + append(subgroups, path: names, into: §ions, ungrouped: &ungrouped) + } + } + } +} diff --git a/TableProTests/Views/FieldDrivenListEntryTests.swift b/TableProTests/Views/FieldDrivenListEntryTests.swift index f4743fb80..098608eed 100644 --- a/TableProTests/Views/FieldDrivenListEntryTests.swift +++ b/TableProTests/Views/FieldDrivenListEntryTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import AppKit @testable import TablePro import Testing @@ -71,6 +72,33 @@ struct FieldDrivenListEntryTests { #expect(first.map(\.identity) != second.map(\.identity)) } + /// Only item rows are refreshed in place, so a header that keeps its identity keeps whatever + /// it was already drawing. A group renamed on another device would have stayed on screen under + /// its old name. + @Test("A renamed section is a different header") + func headerIdentityFollowsItsTitle() { + let before = FieldDrivenListEntry.flatten([section("g", title: "ACME", ["one"])]) + let after = FieldDrivenListEntry.flatten([section("g", title: "ACME CORP", ["one"])]) + + #expect(before.map(\.identity) != after.map(\.identity)) + } + + @Test("A recoloured section is a different header") + func headerIdentityFollowsItsAccent() { + let plain = FieldDrivenListSection(id: "g", title: "ACME", items: [Item(id: "one")]) + let coloured = FieldDrivenListSection( + id: "g", + title: "ACME", + accentColor: .systemRed, + items: [Item(id: "one")] + ) + + #expect( + FieldDrivenListEntry.flatten([plain]).map(\.identity) + != FieldDrivenListEntry.flatten([coloured]).map(\.identity) + ) + } + @Test("A header never reports an item id") func headerHasNoItemId() { let entries = FieldDrivenListEntry.flatten([section("a", title: "ACTIVE", ["one"])]) diff --git a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift index 7e68b1758..bee3c39c2 100644 --- a/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift +++ b/TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift @@ -81,3 +81,215 @@ struct ConnectionSwitcherSelectionTests { #expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: c, by: 1) == c) } } + +@Suite("Connection Switcher Sections") +struct ConnectionSwitcherSectionsTests { + private func connection(_ name: String, groupId: UUID? = nil, sortOrder: Int = 0) -> DatabaseConnection { + DatabaseConnection(name: name, groupId: groupId, sortOrder: sortOrder) + } + + private func titles(_ sections: [FieldDrivenListSection]) -> [String] { + sections.compactMap(\.title) + } + + private func names(_ sections: [FieldDrivenListSection]) -> [[String]] { + sections.map { $0.items.map(\.connection.name) } + } + + @Test("Saved connections are grouped, and the ones in no group come last") + func groupsBecomeSections() { + let acme = ConnectionGroup(name: "Acme", sortOrder: 0) + let saved = [ + connection("acme-local", groupId: acme.id, sortOrder: 0), + connection("acme-prod", groupId: acme.id, sortOrder: 1), + connection("scratch"), + ] + + let sections = ConnectionSwitcherSections.build( + active: [], saved: saved, groups: [acme], isFiltering: false + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "ACME", "UNGROUPED"]) + #expect(names(sections) == [[], ["acme-local", "acme-prod"], ["scratch"]]) + } + + // MARK: - Open without a session + + /// A workspace outlives its session, so a connect that failed, one the user cancelled and an + /// explicit disconnect all leave a connection open with nothing in `activeSessions`. + @Test("A hosted connection with no session is still open") + func hostedWithoutSessionIsOpen() { + let disconnected = connection("acme-prod") + + let open = ConnectionSwitcherSections.hostedWithoutSession( + workspaces: [(disconnected.id, disconnected)], + sessionIds: [], + saved: [disconnected] + ) + + #expect(open.map(\.id) == [disconnected.id]) + } + + @Test("A connection hosted by two windows is listed once") + func hostedTwiceIsListedOnce() { + let detached = connection("acme-prod") + + let open = ConnectionSwitcherSections.hostedWithoutSession( + workspaces: [(detached.id, detached), (detached.id, detached)], + sessionIds: [], + saved: [] + ) + + #expect(open.count == 1) + } + + @Test("A workspace with no record of its own is named by the saved list") + func aWorkspaceWithoutARecordFallsBackToStorage() { + let saved = connection("acme-prod") + + let open = ConnectionSwitcherSections.hostedWithoutSession( + workspaces: [(saved.id, nil)], + sessionIds: [], + saved: [saved] + ) + + #expect(open.map(\.name) == ["acme-prod"]) + } + + @Test("A connection that has a session is left to the session list") + func aSessionBackedConnectionIsNotDuplicated() { + let live = connection("acme-local") + + let open = ConnectionSwitcherSections.hostedWithoutSession( + workspaces: [(live.id, live)], + sessionIds: [live.id], + saved: [live] + ) + + #expect(open.isEmpty) + } + + @Test("With no groups at all the saved connections keep their own name") + func noGroupsKeepsTheSavedHeading() { + let sections = ConnectionSwitcherSections.build( + active: [], saved: [connection("scratch")], groups: [], isFiltering: false + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "SAVED CONNECTIONS"]) + } + + /// The group draws no header once its only connection is open, so the connections beside it + /// are not "ungrouped" against anything the reader can see. + @Test("A group whose connections are all open leaves the saved heading alone") + func anEmptiedGroupDoesNotRenameTheLooseSection() { + let acme = ConnectionGroup(name: "Acme") + let open = connection("acme-local", groupId: acme.id) + + let sections = ConnectionSwitcherSections.build( + active: [ConnectionSwitcherEntry(id: open.id, connection: open, isActive: true, isConnected: true)], + saved: [connection("scratch")], + groups: [acme], + isFiltering: false + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "SAVED CONNECTIONS"]) + } + + @Test("A nested group names its whole path, under the parent that has connections of its own") + func nestedGroupNamesItsPath() { + let acme = ConnectionGroup(name: "Acme") + let europe = ConnectionGroup(name: "Europe", parentId: acme.id) + let saved = [ + connection("acme-prod", groupId: acme.id), + connection("eu-prod", groupId: europe.id), + ] + + let sections = ConnectionSwitcherSections.build( + active: [], saved: saved, groups: [acme, europe], isFiltering: false + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "ACME", "ACME / EUROPE"]) + #expect(names(sections).last == ["eu-prod"]) + } + + /// The parent draws no header of its own, and the child still says where it sits. + @Test("A group holding only subgroups is named through its children rather than on its own") + func aParentWithNoConnectionsOfItsOwnIsSkipped() { + let acme = ConnectionGroup(name: "Acme") + let europe = ConnectionGroup(name: "Europe", parentId: acme.id) + let saved = [connection("eu-prod", groupId: europe.id)] + + let sections = ConnectionSwitcherSections.build( + active: [], saved: saved, groups: [acme, europe], isFiltering: false + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "ACME / EUROPE"]) + } + + @Test("A group carries its own colour onto the header, and no colour means no dot") + func groupColourReachesTheHeader() { + let coloured = ConnectionGroup(name: "Prod", color: .red) + let plain = ConnectionGroup(name: "Scratch", sortOrder: 1) + + let sections = ConnectionSwitcherSections.build( + active: [], + saved: [connection("a", groupId: coloured.id), connection("b", groupId: plain.id)], + groups: [coloured, plain], + isFiltering: false + ) + + #expect(sections.first { $0.title == "PROD" }?.accentColor != nil) + #expect(sections.first { $0.title == "SCRATCH" }?.accentColor == nil) + } + + /// A search is a lookup rather than a browse: one match in each of eight groups would otherwise + /// be eight one-row sections, and a header cannot be selected to get out of them. + @Test("A filter collapses the groups back into one saved section") + func filteringFlattens() { + let acme = ConnectionGroup(name: "Acme") + let saved = [connection("acme-prod", groupId: acme.id), connection("scratch")] + + let sections = ConnectionSwitcherSections.build( + active: [], saved: saved, groups: [acme], isFiltering: true + ) + + #expect(titles(sections) == ["ACTIVE CONNECTIONS", "SAVED CONNECTIONS"]) + #expect(names(sections).last == ["acme-prod", "scratch"]) + } + + @Test("A group whose connections are all open draws no rows of its own") + func emptyGroupDrawsNothing() { + let acme = ConnectionGroup(name: "Acme") + let open = connection("acme-local", groupId: acme.id) + + let sections = ConnectionSwitcherSections.build( + active: [ConnectionSwitcherEntry(id: open.id, connection: open, isActive: true, isConnected: true)], + saved: [], + groups: [acme], + isFiltering: false + ) + let rows = FieldDrivenListEntry.flatten(sections) + + #expect(rows.filter(\.isHeader).count == 1) + #expect(rows.compactMap(\.itemId) == [open.id]) + } + + @Test("The row order the arrow keys walk is the order the sections draw") + func rowOrderFollowsTheSections() { + let acme = ConnectionGroup(name: "Acme") + let open = connection("acme-local", groupId: acme.id) + let saved = [connection("acme-prod", groupId: acme.id), connection("scratch")] + + let sections = ConnectionSwitcherSections.build( + active: [ConnectionSwitcherEntry(id: open.id, connection: open, isActive: true, isConnected: true)], + saved: saved, + groups: [acme], + isFiltering: false + ) + + #expect( + FieldDrivenListEntry.flatten(sections).compactMap(\.itemId) + == [open.id, saved[0].id, saved[1].id] + ) + } +} diff --git a/docs/connections/index.mdx b/docs/connections/index.mdx index 0ec82b885..858b239a6 100644 --- a/docs/connections/index.mdx +++ b/docs/connections/index.mdx @@ -78,11 +78,13 @@ A connection's group, tags, and favorite star sync through iCloud unless it is m ## Switch connections and databases -**Switch Connection** (`Ctrl+Cmd+C`) lists active sessions and saved connections: type to filter, arrow keys to move, Return to switch. **Open Database** (`Cmd+K`) moves to another database on the same server. +Press `Ctrl+Cmd+C` for **Switch Connection**. Whatever is already open sits at the top, and the rest are listed under the group they belong to, a nested group carrying its full path. Arrow keys move, `Return` switches the window to that connection, and `Cmd`-click opens a saved one in a window of its own. Typing searches every group at once and collapses the matches into a single list. + +**Open Database** (`Cmd+K`) moves to another database on the same server. - Database switcher in toolbar - Database switcher in toolbar + Database list with the current database checked + Database list with the current database checked Leaving **Database** empty on MySQL, MariaDB, MongoDB, SQL Server, and ClickHouse browses every database the user can reach. PostgreSQL and Redshift need one to connect at all: use `postgres` (Redshift: `dev`) and switch with `Cmd+K`. To hide the rest, choose **View > Filter Databases** and check the ones you want; the choice is saved per connection.