Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Connections can now have more than one tag. Assign several tags in the connection form, and filter the welcome list by tag with Match Any or Match All. (#744)
- 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 tags and group, so you can tell production from staging at a glance. (#1323)

### Fixed

Expand Down
83 changes: 83 additions & 0 deletions TablePro/Views/Connection/ConnectionMetadataBadges.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// ConnectionMetadataBadges.swift
// TablePro
//

import SwiftUI

enum ConnectionMetadata {
static func resolve(
connection: DatabaseConnection,
tags: [ConnectionTag],
groups: [ConnectionGroup]
) -> (tags: [ConnectionTag], group: ConnectionGroup?) {
let resolvedTags = connection.tagIds.compactMap { id in tags.first { $0.id == id } }
let group = connection.groupId.flatMap { id in groups.first { $0.id == id } }
return (resolvedTags, group)
}
}

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]

var body: some View {
if !tags.isEmpty {
let layout = ConnectionTagsBadgeLayout(tags: tags)
HStack(spacing: 4) {
ForEach(layout.shown) { tag in
Circle()
.fill(tag.color.color)
.frame(width: 8, height: 8)
}
if let name = layout.name {
Text(name)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if layout.overflow > 0 {
Text("+\(layout.overflow)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.help(tags.map(\.name).joined(separator: ", "))
.accessibilityElement(children: .combine)
.accessibilityLabel(String(format: String(localized: "Tags: %@"), tags.map(\.name).joined(separator: ", ")))
}
}
}

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))
}
}
64 changes: 24 additions & 40 deletions TablePro/Views/Connection/WelcomeConnectionRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ struct WelcomeConnectionRow: View {
@State private var isHovering = false
private let pluginManager = PluginManager.shared

private var displayTags: [ConnectionTag] {
TagStorage.shared.tags(for: connection.tagIds)
private var metadata: (tags: [ConnectionTag], group: ConnectionGroup?) {
ConnectionMetadata.resolve(
connection: connection,
tags: TagStorage.shared.loadTags(),
groups: GroupStorage.shared.loadGroups()
)
}

private var showsLocalOnly: Bool {
Expand All @@ -35,7 +39,8 @@ struct WelcomeConnectionRow: View {
}

var body: some View {
HStack {
let meta = metadata
return HStack {
connection.type.iconImage
.renderingMode(.template)
.font(.title3)
Expand All @@ -47,17 +52,24 @@ 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)
.layoutPriority(1)
}
}
}

Spacer(minLength: 8)

trailingAccessories
trailingAccessories(tags: meta.tags)
}
.contentShape(Rectangle())
.onHover { hovering in isHovering = hovering }
Expand All @@ -68,7 +80,7 @@ struct WelcomeConnectionRow: View {
}

@ViewBuilder
private var trailingAccessories: some View {
private func trailingAccessories(tags: [ConnectionTag]) -> some View {
HStack(spacing: 8) {
if isDriverRejected {
Image(systemName: "exclamationmark.triangle.fill")
Expand All @@ -86,40 +98,12 @@ struct WelcomeConnectionRow: View {
.accessibilityLabel(String(localized: "Local only"))
}

tagAccessory
ConnectionTagsBadge(tags: tags)

favoriteButton
}
}

@ViewBuilder
private var tagAccessory: some View {
let tags = displayTags
if !tags.isEmpty {
let shown = Array(tags.prefix(3))
HStack(spacing: 4) {
ForEach(shown) { tag in
Circle()
.fill(tag.color.color)
.frame(width: 8, height: 8)
}
if tags.count == 1 {
Text(tags[0].name)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if tags.count > shown.count {
Text("+\(tags.count - shown.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.help(tags.map(\.name).joined(separator: ", "))
.accessibilityElement(children: .combine)
.accessibilityLabel(String(format: String(localized: "Tags: %@"), tags.map(\.name).joined(separator: ", ")))
}
}

private var favoriteButton: some View {
let visible = connection.isFavorite || isHovering || isSelected
return Button(action: onToggleFavorite) {
Expand Down
24 changes: 19 additions & 5 deletions TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Comment on lines +205 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve active row badges from saved connection metadata

When an already-open connection is edited or moved to a group, the form/group actions update ConnectionStorage but do not refresh DatabaseManager.activeSessions[...].connection; because active rows resolve badges from that session snapshot and the saved copy is filtered out of inactiveSaved, reopening the switcher can keep showing the old tag/group or no group until the connection is reconnected. Use the current saved connection for the metadata lookup when the row is active, or update the session metadata on connection changes.

Useful? React with 👍 / 👎.

return HStack(spacing: 8) {
Circle()
.fill(connection.displayColor)
.frame(width: 8, height: 8)
Expand All @@ -212,14 +217,23 @@ 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)
.layoutPriority(1)
}
}
}

Spacer()

ConnectionTagsBadge(tags: metadata.tags)

if isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Expand Down
110 changes: 110 additions & 0 deletions TableProTests/Models/ConnectionMetadataTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// ConnectionMetadataTests.swift
// TableProTests
//

import Foundation
import TableProPluginKit
import Testing

@testable import TablePro

@Suite("ConnectionMetadata")
struct ConnectionMetadataTests {
private func makeConnection(tagIds: [UUID] = [], groupId: UUID? = nil) -> DatabaseConnection {
var connection = DatabaseConnection(name: "Test")
connection.tagIds = tagIds
connection.groupId = groupId
return connection
}

private let production = ConnectionTag(name: "production", color: .red)
private let staging = ConnectionTag(name: "staging", color: .orange)
private let group = ConnectionGroup(id: UUID(), name: "Backend", sortOrder: 0)

@Test("Resolves assigned tags in order and the assigned group")
func resolvesAssigned() {
let connection = makeConnection(tagIds: [staging.id, production.id], groupId: group.id)

let result = ConnectionMetadata.resolve(
connection: connection,
tags: [production, staging],
groups: [group]
)

#expect(result.tags == [staging, production])
#expect(result.group == group)
}

@Test("Returns no tags and no group when nothing is assigned")
func returnsEmptyWhenUnassigned() {
let connection = makeConnection()

let result = ConnectionMetadata.resolve(
connection: connection,
tags: [production],
groups: [group]
)

#expect(result.tags.isEmpty)
#expect(result.group == nil)
}

@Test("Drops tag and group references that no longer exist")
func dropsStaleReferences() {
let connection = makeConnection(tagIds: [production.id, UUID()], groupId: UUID())

let result = ConnectionMetadata.resolve(
connection: connection,
tags: [production],
groups: [group]
)

#expect(result.tags == [production])
#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)
}
}
2 changes: 2 additions & 0 deletions docs/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
Loading