diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af799f32..dfa04cedc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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) ### Fixed diff --git a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift index a63acd38e..468a68148 100644 --- a/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift +++ b/Packages/TableProCore/Sources/TableProImport/ConnectionExportEnvelope.swift @@ -84,6 +84,7 @@ public struct ExportableConnection: Codable { public let sslConfig: ExportableSSLConfig? public let color: String? public let tagName: String? + public let tagNames: [String]? public let groupName: String? public let sshProfileId: String? public let safeModeLevel: String? @@ -104,6 +105,7 @@ public struct ExportableConnection: Codable { sslConfig: ExportableSSLConfig?, color: String?, tagName: String?, + tagNames: [String]? = nil, groupName: String?, sshProfileId: String?, safeModeLevel: String?, @@ -123,6 +125,7 @@ public struct ExportableConnection: Codable { self.sslConfig = sslConfig self.color = color self.tagName = tagName + self.tagNames = tagNames self.groupName = groupName self.sshProfileId = sshProfileId self.safeModeLevel = safeModeLevel @@ -137,7 +140,7 @@ public struct ExportableConnection: Codable { ExportableConnection( name: newName, host: host, port: port, database: database, username: username, type: type, sshConfig: sshConfig, - sslConfig: sslConfig, color: color, tagName: tagName, + sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames, groupName: groupName, sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, additionalFields: additionalFields, redisDatabase: redisDatabase, @@ -156,7 +159,7 @@ public extension ExportableConnection { return ExportableConnection( name: name, host: host, port: port, database: database, username: username, type: type, sshConfig: sshConfig, - sslConfig: sslConfig, color: color, tagName: tagName, + sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames, groupName: groupName, sshProfileId: sshProfileId, safeModeLevel: safeModeLevel, aiPolicy: aiPolicy, additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase, diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift index cef609a80..df554bd67 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -22,9 +22,14 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { public var sslConfiguration: SSLConfiguration? public var groupId: UUID? - public var tagId: UUID? + public var tagIds: [UUID] public var sortOrder: Int + public var tagId: UUID? { + get { tagIds.first } + set { tagIds = newValue.map { [$0] } ?? [] } + } + public init( id: UUID = UUID(), name: String = "", @@ -43,7 +48,7 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { sslEnabled: Bool = false, sslConfiguration: SSLConfiguration? = nil, groupId: UUID? = nil, - tagId: UUID? = nil, + tagIds: [UUID] = [], sortOrder: Int = 0 ) { self.id = id @@ -63,7 +68,7 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { self.sslEnabled = sslEnabled self.sslConfiguration = sslConfiguration self.groupId = groupId - self.tagId = tagId + self.tagIds = tagIds self.sortOrder = sortOrder } @@ -71,7 +76,7 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { case id, name, type, host, port, username, database, colorTag case isReadOnly, safeModeLevel, queryTimeoutSeconds, additionalFields case sshEnabled, sshConfiguration, sslEnabled, sslConfiguration - case groupId, tagId, sortOrder + case groupId, tagId, tagIds, sortOrder } } @@ -99,7 +104,12 @@ extension DatabaseConnection: Codable { sslEnabled = try container.decodeIfPresent(Bool.self, forKey: .sslEnabled) ?? false sslConfiguration = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfiguration) groupId = try container.decodeIfPresent(UUID.self, forKey: .groupId) - tagId = try container.decodeIfPresent(UUID.self, forKey: .tagId) + let decodedTagIds = try container.decodeIfPresent([UUID].self, forKey: .tagIds) ?? [] + if decodedTagIds.isEmpty { + tagIds = try container.decodeIfPresent(UUID.self, forKey: .tagId).map { [$0] } ?? [] + } else { + tagIds = decodedTagIds + } sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } @@ -122,7 +132,9 @@ extension DatabaseConnection: Codable { try container.encode(sslEnabled, forKey: .sslEnabled) try container.encodeIfPresent(sslConfiguration, forKey: .sslConfiguration) try container.encodeIfPresent(groupId, forKey: .groupId) - try container.encodeIfPresent(tagId, forKey: .tagId) + if !tagIds.isEmpty { + try container.encode(tagIds, forKey: .tagIds) + } try container.encode(sortOrder, forKey: .sortOrder) } } diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index e6db88efd..eb77ba0b4 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -49,8 +49,10 @@ public enum SyncRecordMapper { if let groupId = connection.groupId { record["groupId"] = groupId.uuidString as CKRecordValue } - if let tagId = connection.tagId { - record["tagId"] = tagId.uuidString as CKRecordValue + if !connection.tagIds.isEmpty { + let tagIdStrings = connection.tagIds.map { $0.uuidString } + record["tagIds"] = tagIdStrings as CKRecordValue + record["tagId"] = tagIdStrings[0] as CKRecordValue } if let queryTimeout = connection.queryTimeoutSeconds { record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue @@ -109,7 +111,14 @@ public enum SyncRecordMapper { let username = record["username"] as? String ?? "" let colorTag = record["color"] as? String ?? record["colorTag"] as? String let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } - let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) } + let tagIds: [UUID] + if let rawIds = record["tagIds"] as? [String], !rawIds.isEmpty { + tagIds = rawIds.compactMap { UUID(uuidString: $0) } + } else if let single = (record["tagId"] as? String).flatMap({ UUID(uuidString: $0) }) { + tagIds = [single] + } else { + tagIds = [] + } let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0 let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0 let safeModeLevel = safeModeLevel(fromWire: record["safeModeLevel"] as? String, isReadOnly: isReadOnly) @@ -161,7 +170,7 @@ public enum SyncRecordMapper { sslEnabled: sslEnabled, sslConfiguration: sslConfig, groupId: groupId, - tagId: tagId, + tagIds: tagIds, sortOrder: sortOrder ) } @@ -206,9 +215,12 @@ public enum SyncRecordMapper { record["groupId"] = nil } - if let tagId = connection.tagId { - record["tagId"] = tagId.uuidString as CKRecordValue + if !connection.tagIds.isEmpty { + let tagIdStrings = connection.tagIds.map { $0.uuidString } + record["tagIds"] = tagIdStrings as CKRecordValue + record["tagId"] = tagIdStrings[0] as CKRecordValue } else { + record["tagIds"] = nil record["tagId"] = nil } diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index c9a02019b..433e1660b 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -49,12 +49,8 @@ enum ConnectionExportService { sshConfig = connection.sshConfig } - let tagName: String? - if let tagId = connection.tagId { - tagName = TagStorage.shared.tag(for: tagId)?.name - } else { - tagName = nil - } + let resolvedTagNames = TagStorage.shared.tags(for: connection.tagIds).map { $0.name } + let tagName = resolvedTagNames.first let groupName: String? if let groupId = connection.groupId { @@ -139,6 +135,7 @@ enum ConnectionExportService { sslConfig: exportableSSL, color: color, tagName: tagName, + tagNames: resolvedTagNames.isEmpty ? nil : resolvedTagNames, groupName: groupName, sshProfileId: connection.sshProfileId?.uuidString, safeModeLevel: safeModeLevel, @@ -151,7 +148,7 @@ enum ConnectionExportService { exportableConnections.append(exportable) - if let name = tagName { tagNames.insert(name) } + resolvedTagNames.forEach { tagNames.insert($0) } if let name = groupName { groupNames.insert(name) } } @@ -589,7 +586,8 @@ enum ConnectionExportService { if let color = exportable.color { queryItems.append(URLQueryItem(name: "color", value: color)) } - if let tagName = exportable.tagName { + let exportableTagNames = exportable.tagNames ?? exportable.tagName.map { [$0] } ?? [] + for tagName in exportableTagNames { queryItems.append(URLQueryItem(name: "tagName", value: tagName)) } if let groupName = exportable.groupName { @@ -689,10 +687,9 @@ enum ConnectionExportService { sslConfig = SSLConfiguration() } - // Resolve tag and group by name - let tagId = exportable.tagName.flatMap { name in - tagIdsByName[normalizedLookupKey(name)] - } + // Resolve tags and group by name + let resolvedTagNames = exportable.tagNames ?? exportable.tagName.map { [$0] } ?? [] + let tagIds = resolvedTagNames.compactMap { tagIdsByName[normalizedLookupKey($0)] } let groupId = exportable.groupName.flatMap { name in groupIdsByName[normalizedLookupKey(name)] } @@ -713,7 +710,7 @@ enum ConnectionExportService { sshConfig: sshConfig, sslConfig: sslConfig, color: exportable.color.flatMap { ConnectionColor(rawValue: $0) } ?? .none, - tagId: tagId, + tagIds: tagIds, groupId: groupId, sshProfileId: parsedSSHProfileId, safeModeLevel: exportable.safeModeLevel.flatMap { SafeModeLevel(rawValue: $0) } ?? .silent, diff --git a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift index 2a69a0c2b..2fd1a252d 100644 --- a/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift +++ b/TablePro/Core/Services/Infrastructure/DeeplinkParser.swift @@ -230,6 +230,9 @@ internal enum DeeplinkParser { func value(_ key: String) -> String? { queryItems.first(where: { $0.name == key })?.value } + func values(_ key: String) -> [String] { + queryItems.filter { $0.name == key }.compactMap { $0.value } + } guard let name = value("name"), !name.isEmpty else { return .failure(.missingRequiredParam("name")) @@ -323,6 +326,7 @@ internal enum DeeplinkParser { sslConfig: sslConfig, color: value("color"), tagName: value("tagName"), + tagNames: values("tagName").isEmpty ? nil : values("tagName"), groupName: value("groupName"), sshProfileId: nil, safeModeLevel: value("safeModeLevel"), diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index c6317fc88..7174b1f9d 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -200,6 +200,18 @@ final class ConnectionStorage { return true } + @discardableResult + func removeTagId(_ tagId: UUID) -> Bool { + let affected = loadConnections() + .filter { $0.tagIds.contains(tagId) } + .map { connection -> DatabaseConnection in + var updated = connection + updated.tagIds.removeAll { $0 == tagId } + return updated + } + return updateConnections(affected) + } + @discardableResult func updateSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) -> Bool { var connections = loadConnections() @@ -316,7 +328,7 @@ final class ConnectionStorage { sshConfig: connection.sshConfig, sslConfig: connection.sslConfig, color: connection.color, - tagId: connection.tagId, + tagIds: connection.tagIds, groupId: connection.groupId, sshProfileId: connection.sshProfileId, sshTunnelMode: connection.sshTunnelMode, diff --git a/TablePro/Core/Storage/StoredConnection.swift b/TablePro/Core/Storage/StoredConnection.swift index da96497ad..cdd43ffd2 100644 --- a/TablePro/Core/Storage/StoredConnection.swift +++ b/TablePro/Core/Storage/StoredConnection.swift @@ -30,6 +30,7 @@ struct StoredConnection: Codable { let color: String let tagId: String? + let tagIds: [String]? let groupId: String? let sshProfileId: String? @@ -111,7 +112,8 @@ struct StoredConnection: Codable { self.sslClientKeyPath = connection.sslConfig.clientKeyPath self.color = connection.color.rawValue - self.tagId = connection.tagId?.uuidString + self.tagId = connection.tagIds.first?.uuidString + self.tagIds = connection.tagIds.isEmpty ? nil : connection.tagIds.map { $0.uuidString } self.groupId = connection.groupId?.uuidString self.sshProfileId = connection.sshProfileId?.uuidString @@ -165,7 +167,7 @@ struct StoredConnection: Codable { case sshAgentSocketPath case totpMode, totpAlgorithm, totpDigits, totpPeriod case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath - case color, tagId, groupId, sshProfileId + case color, tagId, tagIds, groupId, sshProfileId case safeModeLevel case externalAccess case isReadOnly // Legacy key for migration reading only @@ -209,6 +211,7 @@ struct StoredConnection: Codable { try container.encode(sslClientKeyPath, forKey: .sslClientKeyPath) try container.encode(color, forKey: .color) try container.encodeIfPresent(tagId, forKey: .tagId) + try container.encodeIfPresent(tagIds, forKey: .tagIds) try container.encodeIfPresent(groupId, forKey: .groupId) try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(safeModeLevel, forKey: .safeModeLevel) @@ -269,6 +272,7 @@ struct StoredConnection: Codable { // Migration: use defaults if fields are missing color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue tagId = try container.decodeIfPresent(String.self, forKey: .tagId) + tagIds = try container.decodeIfPresent([String].self, forKey: .tagIds) groupId = try container.decodeIfPresent(String.self, forKey: .groupId) sshProfileId = try container.decodeIfPresent(String.self, forKey: .sshProfileId) // Migration: read new safeModeLevel first, fall back to old isReadOnly boolean @@ -355,7 +359,14 @@ struct StoredConnection: Codable { ) let parsedColor = ConnectionColor(rawValue: color) ?? .none - let parsedTagId = tagId.flatMap { UUID(uuidString: $0) } + let parsedTagIds: [UUID] + if let ids = tagIds, !ids.isEmpty { + parsedTagIds = ids.compactMap { UUID(uuidString: $0) } + } else if let single = tagId.flatMap({ UUID(uuidString: $0) }) { + parsedTagIds = [single] + } else { + parsedTagIds = [] + } let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } let parsedSSHProfileId = sshProfileId.flatMap { UUID(uuidString: $0) } let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) } @@ -388,7 +399,7 @@ struct StoredConnection: Codable { sshConfig: sshConfig, sslConfig: sslConfig, color: parsedColor, - tagId: parsedTagId, + tagIds: parsedTagIds, groupId: parsedGroupId, sshProfileId: parsedSSHProfileId, sshTunnelMode: resolvedTunnelMode, diff --git a/TablePro/Core/Storage/TagStorage.swift b/TablePro/Core/Storage/TagStorage.swift index 27fc1b608..922223003 100644 --- a/TablePro/Core/Storage/TagStorage.swift +++ b/TablePro/Core/Storage/TagStorage.swift @@ -81,6 +81,14 @@ final class TagStorage { SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString) } + /// Delete a custom tag and clear it from every connection that referenced it. + /// Connections are persisted before the tag tombstone fires (sync delete-ordering invariant). + func deleteTag(_ tag: ConnectionTag, clearingFrom connectionStorage: ConnectionStorage) { + guard !tag.isPreset else { return } + connectionStorage.removeTagId(tag.id) + deleteTag(tag) + } + /// Get tag by ID func tag(for id: UUID) -> ConnectionTag? { loadTags().first { $0.id == id } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 26723c76e..9e8204578 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -83,8 +83,10 @@ struct SyncRecordMapper { record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue record["isFavorite"] = Int64(connection.isFavorite ? 1 : 0) as CKRecordValue - if let tagId = connection.tagId { - record["tagId"] = tagId.uuidString as CKRecordValue + if !connection.tagIds.isEmpty { + let tagIdStrings = connection.tagIds.map { $0.uuidString } + record["tagIds"] = tagIdStrings as CKRecordValue + record["tagId"] = tagIdStrings[0] as CKRecordValue } if let groupId = connection.groupId { record["groupId"] = groupId.uuidString as CKRecordValue @@ -161,7 +163,14 @@ struct SyncRecordMapper { let username = record["username"] as? String ?? "" let colorRaw = record["color"] as? String ?? ConnectionColor.none.rawValue let safeModeLevelRaw = record["safeModeLevel"] as? String ?? SafeModeLevel.silent.rawValue - let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) } + let tagIds: [UUID] + if let rawIds = record["tagIds"] as? [String], !rawIds.isEmpty { + tagIds = rawIds.compactMap { UUID(uuidString: $0) } + } else if let single = (record["tagId"] as? String).flatMap({ UUID(uuidString: $0) }) { + tagIds = [single] + } else { + tagIds = [] + } let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) } let aiPolicyRaw = record["aiPolicy"] as? String let aiRulesRaw = record["aiRules"] as? String @@ -213,7 +222,7 @@ struct SyncRecordMapper { sshConfig: sshConfig, sslConfig: sslConfig, color: ConnectionColor(rawValue: colorRaw) ?? .none, - tagId: tagId, + tagIds: tagIds, groupId: groupId, sshProfileId: sshProfileId, safeModeLevel: SafeModeLevel(rawValue: safeModeLevelRaw) ?? .silent, diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index be9501b29..3a9fe17a9 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -203,7 +203,7 @@ struct ConnectionURLFormatter { params.append("statusColor=\(hex)") } - if let tagId = connection.tagId, + if let tagId = connection.tagIds.first, let tag = TagStorage.shared.tag(for: tagId) { let encoded = tag.name .replacingOccurrences(of: " ", with: "+") diff --git a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift index 91915758c..0b81dc289 100644 --- a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift +++ b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift @@ -34,9 +34,9 @@ internal enum TransientConnectionFactory { color = ConnectionURLParser.connectionColor(fromHex: hex) } - var tagId: UUID? - if let envName = parsed.envTag { - tagId = ConnectionURLParser.tagId(fromEnvName: envName) + var tagIds: [UUID] = [] + if let envName = parsed.envTag, let resolved = ConnectionURLParser.tagId(fromEnvName: envName) { + tagIds = [resolved] } let resolvedSafeMode = parsed.safeModeLevel.flatMap(SafeModeLevel.from(urlInteger:)) ?? .silent @@ -51,7 +51,7 @@ internal enum TransientConnectionFactory { sshConfig: sshConfig, sslConfig: sslConfig, color: color, - tagId: tagId, + tagIds: tagIds, safeModeLevel: resolvedSafeMode, mongoAuthSource: parsed.authSource, mongoUseSrv: parsed.useSrv, diff --git a/TablePro/Models/Connection/ConnectionGroupTree.swift b/TablePro/Models/Connection/ConnectionGroupTree.swift index df8d7e94a..37196ff0a 100644 --- a/TablePro/Models/Connection/ConnectionGroupTree.swift +++ b/TablePro/Models/Connection/ConnectionGroupTree.swift @@ -104,6 +104,23 @@ func filterGroupTree(_ items: [ConnectionGroupTreeNode], searchText: String) -> } } +func filterGroupTreeByTags(_ items: [ConnectionGroupTreeNode], filter: TagFilter) -> [ConnectionGroupTreeNode] { + guard filter.isActive else { return items } + + return items.compactMap { item in + switch item { + case .connection(let conn): + return filter.matches(conn) ? item : nil + case .group(let group, let children): + let filteredChildren = filterGroupTreeByTags(children, filter: filter) + if !filteredChildren.isEmpty { + return .group(group, children: filteredChildren) + } + return nil + } + } +} + // MARK: - Tree Traversal func flattenVisibleConnections( diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 7dd79311c..2c1983b71 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -72,8 +72,8 @@ enum ToolbarConnectionState: Equatable { final class ConnectionToolbarState { // MARK: - Connection Info - /// The tag assigned to this connection (optional) - var tagId: UUID? + /// The tags assigned to this connection + var tagIds: [UUID] = [] /// Database type (MySQL, MariaDB, PostgreSQL, SQLite) var databaseType: DatabaseType = .mysql @@ -239,7 +239,7 @@ final class ConnectionToolbarState { connectionName = connection.name databaseType = connection.type displayColor = connection.displayColor - tagId = connection.tagId + tagIds = connection.tagIds databaseGroupingStrategy = PluginManager.shared.databaseGroupingStrategy(for: connection.type) syncFromSession(for: connection) } @@ -289,7 +289,7 @@ final class ConnectionToolbarState { /// Reset to default disconnected state func reset() { - tagId = nil + tagIds = [] databaseType = .mysql databaseVersion = nil connectionName = "" diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index ad93e640a..f1a82dd18 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -331,7 +331,7 @@ struct DatabaseConnection: Identifiable, Hashable { var sshConfig: SSHConfiguration var sslConfig: SSLConfiguration var color: ConnectionColor - var tagId: UUID? + var tagIds: [UUID] var groupId: UUID? var sshProfileId: UUID? var sshTunnelMode: SSHTunnelMode @@ -429,7 +429,7 @@ struct DatabaseConnection: Identifiable, Hashable { sshConfig: SSHConfiguration = SSHConfiguration(), sslConfig: SSLConfiguration = SSLConfiguration(), color: ConnectionColor = .none, - tagId: UUID? = nil, + tagIds: [UUID] = [], groupId: UUID? = nil, sshProfileId: UUID? = nil, sshTunnelMode: SSHTunnelMode = .disabled, @@ -466,7 +466,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.sshConfig = sshConfig self.sslConfig = sslConfig self.color = color - self.tagId = tagId + self.tagIds = tagIds self.groupId = groupId self.sshProfileId = sshProfileId self.safeModeLevel = safeModeLevel @@ -542,7 +542,7 @@ extension DatabaseConnection { extension DatabaseConnection: Codable { private enum CodingKeys: String, CodingKey { case id, name, host, port, database, username, type - case sshConfig, sslConfig, color, tagId, groupId, sshProfileId + case sshConfig, sslConfig, color, tagId, tagIds, groupId, sshProfileId case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite case passwordSource @@ -560,7 +560,12 @@ extension DatabaseConnection: Codable { sshConfig = try container.decodeIfPresent(SSHConfiguration.self, forKey: .sshConfig) ?? SSHConfiguration() sslConfig = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfig) ?? SSLConfiguration() color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .none - tagId = try container.decodeIfPresent(UUID.self, forKey: .tagId) + let decodedTagIds = try container.decodeIfPresent([UUID].self, forKey: .tagIds) ?? [] + if decodedTagIds.isEmpty { + tagIds = try container.decodeIfPresent(UUID.self, forKey: .tagId).map { [$0] } ?? [] + } else { + tagIds = decodedTagIds + } groupId = try container.decodeIfPresent(UUID.self, forKey: .groupId) sshProfileId = try container.decodeIfPresent(UUID.self, forKey: .sshProfileId) safeModeLevel = try container.decodeIfPresent(SafeModeLevel.self, forKey: .safeModeLevel) ?? .silent @@ -606,7 +611,10 @@ extension DatabaseConnection: Codable { try container.encode(sshConfig, forKey: .sshConfig) try container.encode(sslConfig, forKey: .sslConfig) try container.encode(color, forKey: .color) - try container.encodeIfPresent(tagId, forKey: .tagId) + if !tagIds.isEmpty { + try container.encode(tagIds, forKey: .tagIds) + try container.encode(tagIds[0], forKey: .tagId) + } try container.encodeIfPresent(groupId, forKey: .groupId) try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId) try container.encode(sshTunnelMode, forKey: .sshTunnelMode) diff --git a/TablePro/Models/Connection/TagFilter.swift b/TablePro/Models/Connection/TagFilter.swift new file mode 100644 index 000000000..fa8191a76 --- /dev/null +++ b/TablePro/Models/Connection/TagFilter.swift @@ -0,0 +1,24 @@ +import Foundation + +enum TagFilterMode: String, Codable { + case any + case all +} + +struct TagFilter: Equatable { + var selectedIds: Set = [] + var mode: TagFilterMode = .any + + var isActive: Bool { !selectedIds.isEmpty } + + func matches(_ connection: DatabaseConnection) -> Bool { + guard isActive else { return true } + let ids = Set(connection.tagIds) + switch mode { + case .any: + return !selectedIds.isDisjoint(with: ids) + case .all: + return selectedIds.isSubset(of: ids) + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 307295088..ef55cbc36 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3794,6 +3794,9 @@ } } } + }, + "+%lld" : { + }, "<1ms" : { "localizations" : { @@ -7186,6 +7189,9 @@ } } } + }, + "Add tags" : { + }, "Add the JSON below inside the file and save" : { "localizations" : { @@ -46138,6 +46144,9 @@ } } } + }, + "Match All" : { + }, "Match ALL filters (AND) or ANY filter (OR)" : { "extractionState" : "stale", @@ -46223,6 +46232,9 @@ } } } + }, + "Match Any" : { + }, "matches regex" : { "localizations" : { @@ -77188,6 +77200,7 @@ } }, "Tag: %@" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -77214,6 +77227,12 @@ } } } + }, + "Tags" : { + + }, + "Tags: %@" : { + }, "Template" : { "extractionState" : "stale", diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index a358b2681..2d2f3b17e 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -42,6 +42,7 @@ final class WelcomeViewModel { var connections: [DatabaseConnection] = [] var searchText = "" { didSet { scheduleRebuildTree(oldValue: oldValue) } } + var tagFilter = TagFilter() { didSet { if tagFilter != oldValue { rebuildTree() } } } var selectedConnectionIds: Set = [] var groups: [ConnectionGroup] = [] var linkedConnections: [LinkedConnection] = [] @@ -104,13 +105,22 @@ final class WelcomeViewModel { private(set) var depthByGroup: [UUID: Int] = [:] private(set) var maxDescendantDepthByGroup: [UUID: Int] = [:] + var availableTags: [ConnectionTag] { + let usedIds = Set(connections.flatMap { $0.tagIds }) + return TagStorage.shared.loadTags().filter { usedIds.contains($0.id) } + } + func rebuildTree() { favoriteConnections = connections .filter(\.isFavorite) + .filter { tagFilter.matches($0) } .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } let (tree, indices) = buildGroupTreeWithIndices(groups: groups, connections: connections) - let baseItems = searchText.isEmpty ? tree : filterGroupTree(tree, searchText: searchText) + var baseItems = searchText.isEmpty ? tree : filterGroupTree(tree, searchText: searchText) + if tagFilter.isActive { + baseItems = filterGroupTreeByTags(baseItems, filter: tagFilter) + } if searchText.isEmpty, !favoriteConnections.isEmpty { treeItems = baseItems.filter { node in if case .connection(let conn) = node, conn.isFavorite { return false } @@ -372,6 +382,16 @@ final class WelcomeViewModel { rebuildTree() } + // MARK: - Tags + + func deleteTag(_ tag: ConnectionTag) { + guard !tag.isPreset else { return } + TagStorage.shared.deleteTag(tag, clearingFrom: storage) + connections = storage.loadConnections() + tagFilter.selectedIds.remove(tag.id) + rebuildTree() + } + // MARK: - Groups func requestDeleteGroup(_ group: ConnectionGroup) { diff --git a/TablePro/Views/Connection/ConnectionTagEditor.swift b/TablePro/Views/Connection/ConnectionTagEditor.swift index cf0ccb1ab..00ec05aaf 100644 --- a/TablePro/Views/Connection/ConnectionTagEditor.swift +++ b/TablePro/Views/Connection/ConnectionTagEditor.swift @@ -2,48 +2,77 @@ // ConnectionTagEditor.swift // TablePro // -// Tag selector dropdown for connection form -// import SwiftUI -/// Tag selection for a connection — single Menu dropdown struct ConnectionTagEditor: View { - @Binding var selectedTagId: UUID? + @Binding var tagIds: [UUID] @State private var allTags: [ConnectionTag] = [] @State private var showingCreateSheet = false private let tagStorage = TagStorage.shared - private var selectedTag: ConnectionTag? { - guard let id = selectedTagId else { return nil } - return tagStorage.tag(for: id) + private var selectedTags: [ConnectionTag] { + tagIds.compactMap { id in allTags.first { $0.id == id } } } var body: some View { - Menu { - Button { - selectedTagId = nil - } label: { - HStack { - Text("None") - if selectedTagId == nil { - Spacer() - Image(systemName: "checkmark") - } + HStack(spacing: 6) { + selectionView + tagMenu + } + .task { allTags = tagStorage.loadTags() } + .sheet(isPresented: $showingCreateSheet) { + CreateTagSheet { tagName, tagColor in + let tag = ConnectionTag(name: tagName.lowercased(), isPreset: false, color: tagColor) + tagStorage.addTag(tag) + allTags = tagStorage.loadTags() + if let added = allTags.first(where: { $0.name == tag.name }) { + toggleOn(added.id) } } + } + } - Divider() + @ViewBuilder + private var selectionView: some View { + if selectedTags.isEmpty { + Text("Add tags") + .foregroundStyle(.secondary) + } else { + HStack(spacing: 4) { + ForEach(selectedTags) { tag in + tagChip(tag) + } + } + } + } + private func tagChip(_ tag: ConnectionTag) -> some View { + HStack(spacing: 4) { + Circle() + .fill(tag.color.color) + .frame(width: 7, height: 7) + Text(tag.name) + .lineLimit(1) + } + .padding(.leading, 7) + .padding(.trailing, 8) + .padding(.vertical, 2) + .background(tag.color.color.opacity(0.14), in: Capsule()) + .overlay(Capsule().strokeBorder(tag.color.color.opacity(0.35), lineWidth: 1)) + } + + private var tagMenu: some View { + Menu { ForEach(allTags) { tag in Button { - selectedTagId = tag.id + toggle(tag) } label: { HStack { Image(nsImage: colorDot(tag.color.color)) Text(tag.name) - if selectedTagId == tag.id { + if tagIds.contains(tag.id) { Spacer() Image(systemName: "checkmark") } @@ -73,33 +102,37 @@ struct ConnectionTagEditor: View { } } } label: { - HStack(spacing: 6) { - if let tag = selectedTag { - Circle() - .fill(tag.color.color) - .frame(width: 8, height: 8) - Text(tag.name) - .foregroundStyle(.primary) - } else { - Text("None") - .foregroundStyle(.secondary) - } - } + Image(systemName: "chevron.down") + .imageScale(.small) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) } .menuStyle(.borderlessButton) + .menuIndicator(.hidden) .fixedSize() - .task { allTags = tagStorage.loadTags() } - .sheet(isPresented: $showingCreateSheet) { - CreateTagSheet { tagName, tagColor in - let tag = ConnectionTag(name: tagName.lowercased(), isPreset: false, color: tagColor) - tagStorage.addTag(tag) - selectedTagId = tag.id - allTags = tagStorage.loadTags() - } + } + + private func toggle(_ tag: ConnectionTag) { + if tagIds.contains(tag.id) { + tagIds = tagIds.filter { $0 != tag.id } + } else { + toggleOn(tag.id) } } - /// Create a colored circle NSImage for use in menu items + private func toggleOn(_ id: UUID) { + guard !tagIds.contains(id) else { return } + var ids = tagIds + ids.append(id) + tagIds = ids + } + + private func deleteTag(_ tag: ConnectionTag) { + tagIds = tagIds.filter { $0 != tag.id } + tagStorage.deleteTag(tag, clearingFrom: .shared) + allTags = tagStorage.loadTags() + } + private func colorDot(_ color: Color) -> NSImage { let size = NSSize(width: 10, height: 10) let image = NSImage(size: size, flipped: false) { rect in @@ -110,14 +143,6 @@ struct ConnectionTagEditor: View { image.isTemplate = false return image } - - private func deleteTag(_ tag: ConnectionTag) { - if selectedTagId == tag.id { - selectedTagId = nil - } - tagStorage.deleteTag(tag) - allTags = tagStorage.loadTags() - } } // MARK: - Create Tag Sheet @@ -129,32 +154,27 @@ private struct CreateTagSheet: View { let onSave: (String, ConnectionColor) -> Void var body: some View { - VStack(spacing: 0) { + VStack(spacing: 16) { Text("Create New Tag") .font(.headline) - .padding(.vertical, 12) - Divider() + TextField("Tag name", text: $tagName) + .textFieldStyle(.roundedBorder) + .frame(width: 200) - Form { - Section { - LabeledContent("Name") { - TextField("Tag name", text: $tagName) - } - LabeledContent("Color") { - ColorPaletteView(selectedColor: $tagColor, includesNone: false, size: .compact) - } - } + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.caption) + .foregroundStyle(.secondary) + ColorPaletteView(selectedColor: $tagColor, includesNone: false, size: .compact) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - - Divider() HStack { - Button("Cancel") { dismiss() } - .keyboardShortcut(.cancelAction) - Spacer() + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + Button("Create") { onSave(tagName, tagColor) dismiss() @@ -163,9 +183,9 @@ private struct CreateTagSheet: View { .keyboardShortcut(.defaultAction) .disabled(tagName.trimmingCharacters(in: .whitespaces).isEmpty) } - .padding(12) } - .frame(width: 360) + .padding(20) + .frame(width: 300) .onExitCommand { dismiss() } @@ -174,12 +194,12 @@ private struct CreateTagSheet: View { #Preview { struct PreviewWrapper: View { - @State private var tagId: UUID? + @State private var tagIds: [UUID] = [] var body: some View { VStack(spacing: 20) { - ConnectionTagEditor(selectedTagId: $tagId) - Text("Selected: \(tagId?.uuidString ?? "none")") + ConnectionTagEditor(tagIds: $tagIds) + Text("Selected: \(tagIds.count)") } .padding() .frame(width: 400) diff --git a/TablePro/Views/Connection/TagFilterBar.swift b/TablePro/Views/Connection/TagFilterBar.swift new file mode 100644 index 000000000..1a730edad --- /dev/null +++ b/TablePro/Views/Connection/TagFilterBar.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct TagFilterBar: View { + @Binding var tagFilter: TagFilter + let availableTags: [ConnectionTag] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + if tagFilter.selectedIds.count > 1 { + matchModeMenu + } + ForEach(availableTags) { tag in + tagPill(tag) + } + if tagFilter.isActive { + Button(String(localized: "Clear")) { + tagFilter.selectedIds.removeAll() + } + .buttonStyle(.borderless) + .font(.caption) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + } + + private var matchModeMenu: some View { + Menu { + Button(String(localized: "Match Any")) { tagFilter.mode = .any } + Button(String(localized: "Match All")) { tagFilter.mode = .all } + } label: { + Text(tagFilter.mode == .any ? String(localized: "Match Any") : String(localized: "Match All")) + .font(.caption) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private func tagPill(_ tag: ConnectionTag) -> some View { + let selected = tagFilter.selectedIds.contains(tag.id) + return Button { + if selected { + tagFilter.selectedIds.remove(tag.id) + } else { + tagFilter.selectedIds.insert(tag.id) + } + } label: { + HStack(spacing: 4) { + Circle() + .fill(tag.color.color) + .frame(width: 8, height: 8) + Text(tag.name) + .font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + } + .buttonStyle(.plain) + .background(selected ? tag.color.color.opacity(0.18) : Color.clear, in: Capsule()) + .overlay( + Capsule().strokeBorder( + selected ? tag.color.color : Color.secondary.opacity(0.3), + lineWidth: 1 + ) + ) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index 09003a88b..aa653c722 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -13,9 +13,8 @@ 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 displayTags: [ConnectionTag] { + TagStorage.shared.tags(for: connection.tagIds) } private var showsLocalOnly: Bool { @@ -87,21 +86,37 @@ struct WelcomeConnectionRow: View { .accessibilityLabel(String(localized: "Local only")) } - if let tag = displayTag { - HStack(spacing: 4) { + tagAccessory + + 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) - Text(tag.name) + } + 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) } - .accessibilityElement(children: .combine) - .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) } - - favoriteButton + .help(tags.map(\.name).joined(separator: ", ")) + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: String(localized: "Tags: %@"), tags.map(\.name).joined(separator: ", "))) } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 9096faab0..6e509a15f 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -223,6 +223,10 @@ struct WelcomeWindowView: View { VStack(spacing: 0) { connectionsHeader Divider() + if !vm.availableTags.isEmpty { + TagFilterBar(tagFilter: $vm.tagFilter, availableTags: vm.availableTags) + Divider() + } ZStack { if vm.treeItems.isEmpty && vm.linkedConnections.isEmpty && vm.favoriteConnections.isEmpty { emptyState diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 416d39b30..4dd59895e 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -275,7 +275,7 @@ final class ConnectionFormCoordinator { sshConfig: sshConfig, sslConfig: sslConfig, color: customization.color, - tagId: customization.tagId, + tagIds: customization.tagIds, groupId: customization.groupId, sshProfileId: ssh.state.enabled ? ssh.state.profileId : nil, sshTunnelMode: sshTunnelMode, @@ -458,7 +458,7 @@ final class ConnectionFormCoordinator { sshConfig: sshConfig, sslConfig: sslConfig, color: customization.color, - tagId: customization.tagId, + tagIds: customization.tagIds, groupId: customization.groupId, sshProfileId: ssh.state.enabled ? ssh.state.profileId : nil, sshTunnelMode: testTunnelMode, @@ -790,8 +790,10 @@ final class ConnectionFormCoordinator { if let hex = parsed.statusColor, !hex.isEmpty { customization.color = ConnectionURLParser.connectionColor(fromHex: hex) } - if let env = parsed.envTag, !env.isEmpty { - customization.tagId = ConnectionURLParser.tagId(fromEnvName: env) + if let env = parsed.envTag, !env.isEmpty, + let resolved = ConnectionURLParser.tagId(fromEnvName: env), + !customization.tagIds.contains(resolved) { + customization.tagIds.append(resolved) } if parsed.type.pluginTypeId == "libSQL", !parsed.host.isEmpty { var urlString = "https://\(parsed.host)" diff --git a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift b/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift index b16b1f7ab..464e19d67 100644 --- a/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/CustomizationPaneView.swift @@ -14,8 +14,8 @@ struct CustomizationPaneView: View { LabeledContent(String(localized: "Color")) { ConnectionColorPicker(selectedColor: $coordinator.customization.color) } - LabeledContent(String(localized: "Tag")) { - ConnectionTagEditor(selectedTagId: $coordinator.customization.tagId) + LabeledContent(String(localized: "Tags")) { + ConnectionTagEditor(tagIds: $coordinator.customization.tagIds) } LabeledContent(String(localized: "Group")) { ConnectionGroupPicker(selectedGroupId: $coordinator.customization.groupId) diff --git a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift index 8537b26f4..fe0475022 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/CustomizationPaneViewModel.swift @@ -9,7 +9,7 @@ import Foundation @MainActor final class CustomizationPaneViewModel { var color: ConnectionColor = .none - var tagId: UUID? + var tagIds: [UUID] = [] var groupId: UUID? var safeModeLevel: SafeModeLevel = .silent @@ -19,7 +19,7 @@ final class CustomizationPaneViewModel { func load(from connection: DatabaseConnection) { color = connection.color - tagId = connection.tagId + tagIds = connection.tagIds groupId = connection.groupId safeModeLevel = connection.safeModeLevel } diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 83429c19c..d0eea5ee7 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -22,13 +22,13 @@ struct ToolbarPrincipalContent: View { var onCancelQuery: (() -> Void)? var onSafeModeChange: ((SafeModeLevel) -> Void)? + @State private var showingAllTags = false + var body: some View { - let tag = state.tagId.flatMap { TagStorage.shared.tag(for: $0) } + let tags = TagStorage.shared.tags(for: state.tagIds) HStack(spacing: 10) { - if let tag { - TagBadgeView(tag: tag) - } + tagCluster(tags) ConnectionStatusView( databaseType: state.databaseType, @@ -56,4 +56,52 @@ struct ToolbarPrincipalContent: View { } .padding(.horizontal, ToolbarPrincipalLayout.edgePadding) } + + @ViewBuilder + private func tagCluster(_ tags: [ConnectionTag]) -> some View { + if let first = tags.first { + let names = tags.map(\.name).joined(separator: ", ") + let overflow = tags.count - 1 + + Button { + showingAllTags = true + } label: { + HStack(spacing: 4) { + tagBadge(first) + if overflow > 0 { + Text(verbatim: "+\(overflow)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(names) + .accessibilityLabel(String(format: String(localized: "Tags: %@"), names)) + .popover(isPresented: $showingAllTags, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 6) { + ForEach(tags) { tag in + HStack(spacing: 6) { + Circle() + .fill(tag.color.color) + .frame(width: 8, height: 8) + Text(tag.name) + } + } + } + .padding(12) + } + } + } + + private func tagBadge(_ tag: ConnectionTag) -> some View { + Text(tag.name.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(tag.color.color, in: Capsule()) + } } diff --git a/TablePro/Views/Toolbar/TagBadgeView.swift b/TablePro/Views/Toolbar/TagBadgeView.swift deleted file mode 100644 index 98530ec8e..000000000 --- a/TablePro/Views/Toolbar/TagBadgeView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// TagBadgeView.swift -// TablePro -// -// Tag badge for toolbar display showing connection environment. -// Uses capsule background with colored text matching tag color. -// - -import SwiftUI - -/// Compact badge showing the connection's tag with capsule background -struct TagBadgeView: View { - let tag: ConnectionTag - - /// Display name with validation for empty/whitespace tags - private var displayName: String { - let trimmed = tag.name.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "UNTAGGED" : trimmed.uppercased() - } - - var body: some View { - Text(displayName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(tag.color.color, in: Capsule()) - .help(String(format: String(localized: "Tag: %@"), tag.name)) - .accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name)) - } -} - -// MARK: - Preview - -#Preview("Tag Badges") { - VStack(spacing: 12) { - TagBadgeView(tag: ConnectionTag(name: "local", isPreset: true, color: .green)) - TagBadgeView(tag: ConnectionTag(name: "production", isPreset: true, color: .red)) - TagBadgeView(tag: ConnectionTag(name: "development", isPreset: true, color: .blue)) - TagBadgeView(tag: ConnectionTag(name: "testing", isPreset: true, color: .orange)) - } - .padding() - .background(Color(nsColor: .windowBackgroundColor)) -} diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index fd6f629b7..ff96181e5 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -278,8 +278,8 @@ final class AppState { persist(tags: updatedTags) var updatedConnections = connections - for index in updatedConnections.indices where updatedConnections[index].tagId == tagId { - updatedConnections[index].tagId = nil + for index in updatedConnections.indices where updatedConnections[index].tagIds.contains(tagId) { + updatedConnections[index].tagIds.removeAll { $0 == tagId } syncCoordinator.markDirty(updatedConnections[index].id) } persist(connections: updatedConnections) diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift index 4208e3200..3fe2bf112 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionExportService.swift @@ -41,9 +41,10 @@ enum IOSConnectionExportService { var tagNames: Set = [] let exportables: [ExportableConnection] = connections.map { connection in - let tagName = appState.tag(for: connection.tagId)?.name + let connectionTagNames = connection.tagIds.compactMap { appState.tag(for: $0)?.name } + let tagName = connectionTagNames.first let groupName = appState.group(for: connection.groupId)?.name - if let tagName { tagNames.insert(tagName) } + connectionTagNames.forEach { tagNames.insert($0) } if let groupName { groupNames.insert(groupName) } return ExportableConnection( @@ -58,6 +59,7 @@ enum IOSConnectionExportService { color: (connection.colorTag?.isEmpty == false && connection.colorTag != ConnectionColor.none.rawValue) ? connection.colorTag : nil, tagName: tagName, + tagNames: connectionTagNames.isEmpty ? nil : connectionTagNames, groupName: groupName, sshProfileId: nil, safeModeLevel: connection.safeModeLevel == .off ? nil : connection.safeModeLevel.rawValue, diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 3063fa161..14c1a86a1 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -182,7 +182,8 @@ enum IOSConnectionImportService { sslEnabled: sslEnabled, sslConfiguration: sslConfiguration, groupId: exportable.groupName.flatMap { groupIdsByName[normalizedKey($0)] }, - tagId: exportable.tagName.flatMap { tagIdsByName[normalizedKey($0)] }, + tagIds: (exportable.tagNames ?? exportable.tagName.map { [$0] } ?? []) + .compactMap { tagIdsByName[normalizedKey($0)] }, sortOrder: sortOrder ) } diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index d1f9aae94..310139785 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -382,7 +382,7 @@ final class ConnectionFormViewModel { sshEnabled: sshEnabled, sslEnabled: type == .mssql ? (mssqlSSLMode != .disable) : sslEnabled, groupId: groupId, - tagId: tagId + tagIds: tagId.map { [$0] } ?? [] ) if type == .mssql { conn.sslConfiguration = SSLConfiguration(mode: mssqlSSLMode) diff --git a/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift index ab962aed8..0468eaaee 100644 --- a/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/ConnectionFormViewModelTests.swift @@ -20,7 +20,7 @@ struct ConnectionFormViewModelTests { sshEnabled: false, sslEnabled: true, groupId: nil, - tagId: nil + tagIds: [] ) conn.safeModeLevel = .readOnly return conn diff --git a/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift b/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift new file mode 100644 index 000000000..9eb839b77 --- /dev/null +++ b/TableProTests/Core/Storage/ConnectionStorageRemoveTagTests.swift @@ -0,0 +1,74 @@ +// +// ConnectionStorageRemoveTagTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ConnectionStorage removeTagId") +@MainActor +struct ConnectionStorageRemoveTagTests { + private let storage: ConnectionStorage + + init() { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connections_\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let defaultsName = "com.TablePro.tests.ConnectionStorage.RemoveTag.\(unique)" + let syncName = "com.TablePro.tests.Sync.RemoveTag.\(unique)" + guard let defaults = UserDefaults(suiteName: defaultsName), + let syncDefaults = UserDefaults(suiteName: syncName) else { + fatalError("UserDefaults suite creation failed in test setup") + } + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage( + fileURL: fileURL, + userDefaults: defaults, + syncTracker: tracker + ) + } + + private func connection(name: String, tagIds: [UUID]) -> DatabaseConnection { + var connection = DatabaseConnection(name: name, type: .postgresql) + connection.tagIds = tagIds + return connection + } + + @Test("removeTagId clears the tag from every connection that referenced it") + func clearsFromAllConnections() { + let shared = UUID() + let other = UUID() + let conn1 = connection(name: "A", tagIds: [shared, other]) + let conn2 = connection(name: "B", tagIds: [shared]) + let conn3 = connection(name: "C", tagIds: [other]) + storage.addConnection(conn1) + storage.addConnection(conn2) + storage.addConnection(conn3) + + storage.removeTagId(shared) + + let loaded = storage.loadConnections() + #expect(loaded.first { $0.id == conn1.id }?.tagIds == [other]) + #expect(loaded.first { $0.id == conn2.id }?.tagIds.isEmpty == true) + #expect(loaded.first { $0.id == conn3.id }?.tagIds == [other]) + } + + @Test("removeTagId is a no-op when no connection uses the tag") + func noOpWhenUnused() { + let conn = connection(name: "A", tagIds: [UUID()]) + storage.addConnection(conn) + + let result = storage.removeTagId(UUID()) + + #expect(result) + #expect(storage.loadConnections().first { $0.id == conn.id }?.tagIds.count == 1) + } +} diff --git a/TableProTests/Core/Storage/StoredConnectionTagTests.swift b/TableProTests/Core/Storage/StoredConnectionTagTests.swift new file mode 100644 index 000000000..63419430e --- /dev/null +++ b/TableProTests/Core/Storage/StoredConnectionTagTests.swift @@ -0,0 +1,64 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("StoredConnection tag persistence") +struct StoredConnectionTagTests { + @Test("Round trips multiple tag IDs") + func roundTripMultiple() throws { + let a = UUID() + let b = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [a, b] + + let stored = StoredConnection(from: connection) + let data = try JSONEncoder().encode(stored) + let decoded = try JSONDecoder().decode(StoredConnection.self, from: data) + + #expect(decoded.toConnection().tagIds == [a, b]) + } + + @Test("Writes both legacy tagId and tagIds for backward compatibility") + func writesBackwardCompatField() throws { + let a = UUID() + let b = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [a, b] + + let data = try JSONEncoder().encode(StoredConnection(from: connection)) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["tagId"] as? String == a.uuidString) + #expect(object["tagIds"] as? [String] == [a.uuidString, b.uuidString]) + } + + @Test("Legacy single tagId promotes to tagIds") + func legacyPromotes() throws { + let tagId = UUID() + let json: [String: Any] = [ + "id": UUID().uuidString, + "name": "Local", + "host": "localhost", + "port": 3306, + "database": "", + "username": "root", + "type": "mysql", + "sshEnabled": false, + "sshHost": "", + "sshUsername": "", + "sshAuthMethod": "password", + "sshPrivateKeyPath": "", + "sslMode": "disabled", + "color": "none", + "tagId": tagId.uuidString, + "safeModeLevel": "silent", + "externalAccess": "readOnly", + "sortOrder": 0, + "localOnly": false, + "isSample": false, + "isFavorite": false, + ] + let data = try JSONSerialization.data(withJSONObject: json) + let stored = try JSONDecoder().decode(StoredConnection.self, from: data) + #expect(stored.toConnection().tagIds == [tagId]) + } +} diff --git a/TableProTests/Core/Sync/SyncRecordMapperTagTests.swift b/TableProTests/Core/Sync/SyncRecordMapperTagTests.swift new file mode 100644 index 000000000..d50aca924 --- /dev/null +++ b/TableProTests/Core/Sync/SyncRecordMapperTagTests.swift @@ -0,0 +1,55 @@ +import CloudKit +import Foundation +@testable import TablePro +import Testing + +@Suite("SyncRecordMapper connection tags") +struct SyncRecordMapperTagTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + @Test("Writes both tagIds array and legacy tagId") + func writesBothFields() { + let a = UUID() + let b = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [a, b] + + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + + #expect(record["tagIds"] as? [String] == [a.uuidString, b.uuidString]) + #expect(record["tagId"] as? String == a.uuidString) + } + + @Test("Prefers tagIds array when reading a record") + func prefersTagIds() throws { + let a = UUID() + let b = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [a, b] + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + + let decoded = try SyncRecordMapper.toConnection(record) + #expect(decoded.tagIds == [a, b]) + } + + @Test("Falls back to legacy tagId when tagIds absent") + func fallsBackToTagId() throws { + let legacy = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [legacy] + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + record["tagIds"] = nil + + let decoded = try SyncRecordMapper.toConnection(record) + #expect(decoded.tagIds == [legacy]) + } + + @Test("No tag fields decodes to empty array") + func emptyTags() throws { + let connection = DatabaseConnection(name: "Local") + let record = SyncRecordMapper.toCKRecord(connection, in: zoneID) + + let decoded = try SyncRecordMapper.toConnection(record) + #expect(decoded.tagIds.isEmpty) + } +} diff --git a/TableProTests/Models/Connection/DatabaseConnectionTagMigrationTests.swift b/TableProTests/Models/Connection/DatabaseConnectionTagMigrationTests.swift new file mode 100644 index 000000000..23f21e4f4 --- /dev/null +++ b/TableProTests/Models/Connection/DatabaseConnectionTagMigrationTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("DatabaseConnection tag migration") +struct DatabaseConnectionTagMigrationTests { + private func decode(_ json: [String: Any]) throws -> DatabaseConnection { + let data = try JSONSerialization.data(withJSONObject: json) + return try JSONDecoder().decode(DatabaseConnection.self, from: data) + } + + @Test("Legacy single tagId promotes to tagIds") + func legacyTagIdPromotes() throws { + let tagId = UUID() + let connection = try decode([ + "id": UUID().uuidString, + "name": "Local", + "tagId": tagId.uuidString, + ]) + #expect(connection.tagIds == [tagId]) + } + + @Test("tagIds is preferred over legacy tagId when both present") + func tagIdsPreferred() throws { + let legacy = UUID() + let a = UUID() + let b = UUID() + let connection = try decode([ + "id": UUID().uuidString, + "name": "Local", + "tagId": legacy.uuidString, + "tagIds": [a.uuidString, b.uuidString], + ]) + #expect(connection.tagIds == [a, b]) + } + + @Test("No tag keys decodes to empty array") + func noTagsEmpty() throws { + let connection = try decode([ + "id": UUID().uuidString, + "name": "Local", + ]) + #expect(connection.tagIds.isEmpty) + } + + @Test("Encoding writes tagIds plus the first tag as legacy tagId for downgrade safety") + func encodeWritesTagIdsAndLegacyFirst() throws { + let a = UUID() + let b = UUID() + var connection = DatabaseConnection(name: "Local") + connection.tagIds = [a, b] + let data = try JSONEncoder().encode(connection) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["tagIds"] as? [String] == [a.uuidString, b.uuidString]) + #expect(object["tagId"] as? String == a.uuidString) + } + + @Test("Empty tagIds omits the key on encode") + func encodeEmptyOmitsKey() throws { + let connection = DatabaseConnection(name: "Local") + let data = try JSONEncoder().encode(connection) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["tagIds"] == nil) + } +} diff --git a/TableProTests/Models/Connection/TagFilterTests.swift b/TableProTests/Models/Connection/TagFilterTests.swift new file mode 100644 index 000000000..e1ccf32f4 --- /dev/null +++ b/TableProTests/Models/Connection/TagFilterTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("TagFilter") +struct TagFilterTests { + private func connection(tagIds: [UUID]) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Conn") + connection.tagIds = tagIds + return connection + } + + @Test("Inactive filter matches everything") + func inactiveMatchesAll() { + let filter = TagFilter() + #expect(filter.matches(connection(tagIds: []))) + #expect(filter.matches(connection(tagIds: [UUID()]))) + } + + @Test("Match any matches when at least one tag overlaps") + func matchAny() { + let a = UUID() + let b = UUID() + let c = UUID() + let filter = TagFilter(selectedIds: [a, b], mode: .any) + #expect(filter.matches(connection(tagIds: [b, c]))) + #expect(!filter.matches(connection(tagIds: [c]))) + } + + @Test("Match all requires every selected tag") + func matchAll() { + let a = UUID() + let b = UUID() + let c = UUID() + let filter = TagFilter(selectedIds: [a, b], mode: .all) + #expect(filter.matches(connection(tagIds: [a, b, c]))) + #expect(!filter.matches(connection(tagIds: [a]))) + } + + @Test("filterGroupTreeByTags keeps matching connections and prunes groups without matches") + func treeFilter() { + let a = UUID() + let matching = connection(tagIds: [a]) + let other = connection(tagIds: [UUID()]) + let group = ConnectionGroup(name: "Prod") + let tree: [ConnectionGroupTreeNode] = [ + .group(group, children: [.connection(matching), .connection(other)]), + .connection(other), + ] + + let filtered = filterGroupTreeByTags(tree, filter: TagFilter(selectedIds: [a], mode: .any)) + + #expect(filtered.count == 1) + guard case .group(_, let children) = filtered[0] else { + Issue.record("Expected a group node") + return + } + #expect(children.count == 1) + guard case .connection(let conn) = children[0] else { + Issue.record("Expected a connection node") + return + } + #expect(conn.id == matching.id) + } + + @Test("filterGroupTreeByTags returns input unchanged when filter inactive") + func treeFilterInactive() { + let tree: [ConnectionGroupTreeNode] = [.connection(connection(tagIds: []))] + let filtered = filterGroupTreeByTags(tree, filter: TagFilter()) + #expect(filtered.count == 1) + } +} diff --git a/docs/external-api/url-scheme.mdx b/docs/external-api/url-scheme.mdx index 4bec5423f..d5b7ee245 100644 --- a/docs/external-api/url-scheme.mdx +++ b/docs/external-api/url-scheme.mdx @@ -153,7 +153,7 @@ open "tablepro://import?name=Staging&host=db.example.com&port=5432&type=postgres | `username` | Database username. | | `database` | Default database name. | | `color` | Connection color in the sidebar. | -| `tagName` | Tag to assign. | +| `tagName` | Tag to assign. Repeat the parameter to assign more than one tag. | | `groupName` | Group to place the connection in. | | `safeModeLevel` | Safe Mode level: `silent`, `alert`, `alertFull`, `safeMode`, `safeModeFull`, or `readOnly`. | | `aiPolicy` | AI access policy: `useDefault`, `alwaysAllow`, `askEachTime`, or `never`. |