From 67cd678f53483e4a91a68e39c312d6f5d7593b4c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 22 Jun 2026 23:27:23 +0700 Subject: [PATCH 1/2] feat(connections): show tag and group in the connection switcher and welcome list (#1323) --- CHANGELOG.md | 1 + .../Connection/ConnectionMetadataBadges.swift | 58 +++++++++++++++++++ .../Connection/WelcomeConnectionRow.swift | 48 +++++++-------- .../Toolbar/ConnectionSwitcherPopover.swift | 25 ++++++-- .../Models/ConnectionMetadataTests.swift | 53 +++++++++++++++++ docs/quickstart.mdx | 2 + 6 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 TablePro/Views/Connection/ConnectionMetadataBadges.swift create mode 100644 TableProTests/Models/ConnectionMetadataTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af799f32..22ca7ccde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) +- The connection switcher and welcome list now show each connection's tag and group, so you can tell production from staging at a glance. (#1323) ### Fixed diff --git a/TablePro/Views/Connection/ConnectionMetadataBadges.swift b/TablePro/Views/Connection/ConnectionMetadataBadges.swift new file mode 100644 index 000000000..7b30ec83b --- /dev/null +++ b/TablePro/Views/Connection/ConnectionMetadataBadges.swift @@ -0,0 +1,58 @@ +// +// ConnectionMetadataBadges.swift +// TablePro +// + +import SwiftUI + +enum ConnectionMetadata { + static func resolve( + connection: DatabaseConnection, + tags: [ConnectionTag], + groups: [ConnectionGroup] + ) -> (tag: ConnectionTag?, group: ConnectionGroup?) { + let tag = connection.tagId.flatMap { id in tags.first { $0.id == id } } + let group = connection.groupId.flatMap { id in groups.first { $0.id == id } } + return (tag, group) + } +} + +struct ConnectionTagBadge: View { + let tag: ConnectionTag + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(tag.color.color) + .frame(width: 8, height: 8) + Text(tag.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) + } +} + +struct ConnectionGroupBadge: View { + let group: ConnectionGroup + + private var iconColor: Color { + group.color.isDefault ? .secondary : group.color.color + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "folder") + .imageScale(.small) + .foregroundStyle(iconColor) + Text(group.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: String(localized: "Group: %@"), group.name)) + } +} diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 09003a88b..cc48241cb 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -13,9 +13,12 @@ struct WelcomeConnectionRow: View { @State private var isHovering = false private let pluginManager = PluginManager.shared - private var displayTag: ConnectionTag? { - guard let tagId = connection.tagId else { return nil } - return TagStorage.shared.tag(for: tagId) + private var metadata: (tag: ConnectionTag?, group: ConnectionGroup?) { + ConnectionMetadata.resolve( + connection: connection, + tags: TagStorage.shared.loadTags(), + groups: GroupStorage.shared.loadGroups() + ) } private var showsLocalOnly: Bool { @@ -36,7 +39,8 @@ struct WelcomeConnectionRow: View { } var body: some View { - HStack { + let meta = metadata + return HStack { connection.type.iconImage .renderingMode(.template) .font(.title3) @@ -48,17 +52,23 @@ struct WelcomeConnectionRow: View { .font(.body) .foregroundStyle(.primary) - Text(connection.connectionSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .help(connection.connectionSubtitle) + HStack(spacing: 6) { + Text(connection.connectionSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(connection.connectionSubtitle) + + if let group = meta.group { + ConnectionGroupBadge(group: group) + } + } } Spacer(minLength: 8) - trailingAccessories + trailingAccessories(tag: meta.tag) } .contentShape(Rectangle()) .onHover { hovering in isHovering = hovering } @@ -69,7 +79,7 @@ struct WelcomeConnectionRow: View { } @ViewBuilder - private var trailingAccessories: some View { + private func trailingAccessories(tag: ConnectionTag?) -> some View { HStack(spacing: 8) { if isDriverRejected { Image(systemName: "exclamationmark.triangle.fill") @@ -87,18 +97,8 @@ struct WelcomeConnectionRow: View { .accessibilityLabel(String(localized: "Local only")) } - if let tag = displayTag { - HStack(spacing: 4) { - Circle() - .fill(tag.color.color) - .frame(width: 8, height: 8) - Text(tag.name) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .accessibilityElement(children: .combine) - .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) + if let tag { + ConnectionTagBadge(tag: tag) } favoriteButton diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 4b7c38892..54ade6781 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -202,7 +202,12 @@ struct ConnectionSwitcherPopover: View { isActive: Bool, isConnected: Bool ) -> some View { - HStack(spacing: 8) { + let metadata = ConnectionMetadata.resolve( + connection: connection, + tags: TagStorage.shared.loadTags(), + groups: GroupStorage.shared.loadGroups() + ) + return HStack(spacing: 8) { Circle() .fill(connection.displayColor) .frame(width: 8, height: 8) @@ -212,14 +217,24 @@ struct ConnectionSwitcherPopover: View { .font(.body.weight(isActive ? .semibold : .regular)) .lineLimit(1) - Text(connectionSubtitle(connection)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 6) { + Text(connectionSubtitle(connection)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let group = metadata.group { + ConnectionGroupBadge(group: group) + } + } } Spacer() + if let tag = metadata.tag { + ConnectionTagBadge(tag: tag) + } + if isActive { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) diff --git a/TableProTests/Models/ConnectionMetadataTests.swift b/TableProTests/Models/ConnectionMetadataTests.swift new file mode 100644 index 000000000..5a08916ea --- /dev/null +++ b/TableProTests/Models/ConnectionMetadataTests.swift @@ -0,0 +1,53 @@ +// +// ConnectionMetadataTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("ConnectionMetadata") +struct ConnectionMetadataTests { + private func makeConnection(tagId: UUID? = nil, groupId: UUID? = nil) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Test") + connection.tagId = tagId + connection.groupId = groupId + return connection + } + + private let tag = ConnectionTag(name: "production", color: .red) + private let group = ConnectionGroup(id: UUID(), name: "Backend", sortOrder: 0) + + @Test("Resolves the assigned tag and group") + func resolvesAssigned() { + let connection = makeConnection(tagId: tag.id, groupId: group.id) + + let result = ConnectionMetadata.resolve(connection: connection, tags: [tag], groups: [group]) + + #expect(result.tag == tag) + #expect(result.group == group) + } + + @Test("Returns nil when no tag or group is assigned") + func returnsNilWhenUnassigned() { + let connection = makeConnection() + + let result = ConnectionMetadata.resolve(connection: connection, tags: [tag], groups: [group]) + + #expect(result.tag == nil) + #expect(result.group == nil) + } + + @Test("Returns nil when the assigned tag or group no longer exists") + func returnsNilForStaleReferences() { + let connection = makeConnection(tagId: UUID(), groupId: UUID()) + + let result = ConnectionMetadata.resolve(connection: connection, tags: [tag], groups: [group]) + + #expect(result.tag == nil) + #expect(result.group == nil) + } +} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index aaa49f98e..687e10e4b 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -43,6 +43,8 @@ Or manually: Download from [GitHub Releases](https://github.com/TableProApp/Tabl The welcome window has a sidebar of saved connections on the left and a detail panel on the right. Search is in the window toolbar; press `Cmd+F` to focus it. The toolbar `+` button starts a new connection, and the folder button creates a connection group. +Each connection shows its tag and group, so you can tell production from staging at a glance. The same labels appear in the connection switcher in the editor toolbar. + First launch ships a bundled Chinook sample database. Open it with one click to try TablePro without setting up a server. Reset the sample from **File > Reset Sample Database** any time. To make a real connection, click **Create connection** in the empty state (or the toolbar `+`). A chooser sheet appears with every supported database type grouped by category. Pick one and click **Continue**. From c666ebac1cba41aa717dbe0198f16e4e2f11d86c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 01:20:58 +0700 Subject: [PATCH 2/2] refactor(connections): extract testable tag-badge layout and prioritize group label --- .../Connection/ConnectionMetadataBadges.swift | 26 ++++++++--- .../Connection/WelcomeConnectionRow.swift | 1 + .../Toolbar/ConnectionSwitcherPopover.swift | 1 + .../Models/ConnectionMetadataTests.swift | 44 +++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Connection/ConnectionMetadataBadges.swift b/TablePro/Views/Connection/ConnectionMetadataBadges.swift index 6312f207d..2c8530819 100644 --- a/TablePro/Views/Connection/ConnectionMetadataBadges.swift +++ b/TablePro/Views/Connection/ConnectionMetadataBadges.swift @@ -17,26 +17,38 @@ enum ConnectionMetadata { } } +struct ConnectionTagsBadgeLayout: Equatable { + let shown: [ConnectionTag] + let overflow: Int + let name: String? + + init(tags: [ConnectionTag]) { + let visible = Array(tags.prefix(3)) + shown = visible + overflow = tags.count - visible.count + name = tags.count == 1 ? tags.first?.name : nil + } +} + struct ConnectionTagsBadge: View { let tags: [ConnectionTag] - private var shown: [ConnectionTag] { Array(tags.prefix(3)) } - var body: some View { if !tags.isEmpty { + let layout = ConnectionTagsBadgeLayout(tags: tags) HStack(spacing: 4) { - ForEach(shown) { tag in + ForEach(layout.shown) { tag in Circle() .fill(tag.color.color) .frame(width: 8, height: 8) } - if tags.count == 1 { - Text(tags[0].name) + if let name = layout.name { + Text(name) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) - } else if tags.count > shown.count { - Text("+\(tags.count - shown.count)") + } else if layout.overflow > 0 { + Text("+\(layout.overflow)") .font(.caption) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 46361d3db..736dbcb76 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -62,6 +62,7 @@ struct WelcomeConnectionRow: View { if let group = meta.group { ConnectionGroupBadge(group: group) + .layoutPriority(1) } } } diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index f00c04f81..41ff1fa3b 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -225,6 +225,7 @@ struct ConnectionSwitcherPopover: View { if let group = metadata.group { ConnectionGroupBadge(group: group) + .layoutPriority(1) } } } diff --git a/TableProTests/Models/ConnectionMetadataTests.swift b/TableProTests/Models/ConnectionMetadataTests.swift index bd02aa2d0..a9d375f7b 100644 --- a/TableProTests/Models/ConnectionMetadataTests.swift +++ b/TableProTests/Models/ConnectionMetadataTests.swift @@ -64,3 +64,47 @@ struct ConnectionMetadataTests { #expect(result.group == nil) } } + +@Suite("ConnectionTagsBadgeLayout") +struct ConnectionTagsBadgeLayoutTests { + private func tags(_ count: Int) -> [ConnectionTag] { + (0 ..< count).map { ConnectionTag(name: "tag\($0)") } + } + + @Test("Empty input shows nothing") + func empty() { + let layout = ConnectionTagsBadgeLayout(tags: []) + + #expect(layout.shown.isEmpty) + #expect(layout.overflow == 0) + #expect(layout.name == nil) + } + + @Test("A single tag shows its name and no overflow") + func single() { + let tag = ConnectionTag(name: "production") + let layout = ConnectionTagsBadgeLayout(tags: [tag]) + + #expect(layout.shown == [tag]) + #expect(layout.overflow == 0) + #expect(layout.name == "production") + } + + @Test("Up to three tags show dots only, no name and no overflow") + func threeShowDotsOnly() { + let layout = ConnectionTagsBadgeLayout(tags: tags(3)) + + #expect(layout.shown.count == 3) + #expect(layout.overflow == 0) + #expect(layout.name == nil) + } + + @Test("More than three tags cap at three dots and report the overflow count") + func overflow() { + let layout = ConnectionTagsBadgeLayout(tags: tags(5)) + + #expect(layout.shown.count == 3) + #expect(layout.overflow == 2) + #expect(layout.name == nil) + } +}