Skip to content

Commit c3497e9

Browse files
committed
feat: add connection groups
1 parent 4c6ca39 commit c3497e9

9 files changed

Lines changed: 858 additions & 38 deletions

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ final class ConnectionStorage {
116116
type: connection.type,
117117
sshConfig: connection.sshConfig,
118118
color: connection.color,
119-
tagId: connection.tagId
119+
tagId: connection.tagId,
120+
groupId: connection.groupId
120121
)
121122

122123
// Save the duplicate connection
@@ -356,6 +357,10 @@ private struct StoredConnection: Codable {
356357
// AI policy
357358
let aiPolicy: String?
358359

360+
// Group
361+
let groupId: String?
362+
let sortOrder: Int
363+
359364
init(from connection: DatabaseConnection) {
360365
self.id = connection.id
361366
self.name = connection.name
@@ -389,6 +394,10 @@ private struct StoredConnection: Codable {
389394

390395
// AI policy
391396
self.aiPolicy = connection.aiPolicy?.rawValue
397+
398+
// Group
399+
self.groupId = connection.groupId?.uuidString
400+
self.sortOrder = connection.sortOrder
392401
}
393402

394403
// Custom decoder to handle migration from old format
@@ -424,6 +433,8 @@ private struct StoredConnection: Codable {
424433
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
425434
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
426435
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
436+
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
437+
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
427438
}
428439

429440
func toConnection() -> DatabaseConnection {
@@ -447,6 +458,7 @@ private struct StoredConnection: Codable {
447458
let parsedColor = ConnectionColor(rawValue: color) ?? .none
448459
let parsedTagId = tagId.flatMap { UUID(uuidString: $0) }
449460
let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }
461+
let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) }
450462

451463
return DatabaseConnection(
452464
id: id,
@@ -461,7 +473,9 @@ private struct StoredConnection: Codable {
461473
color: parsedColor,
462474
tagId: parsedTagId,
463475
isReadOnly: isReadOnly,
464-
aiPolicy: parsedAIPolicy
476+
aiPolicy: parsedAIPolicy,
477+
groupId: parsedGroupId,
478+
sortOrder: sortOrder
465479
)
466480
}
467481
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
//
2+
// GroupStorage.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Service for persisting connection groups
10+
final class GroupStorage {
11+
static let shared = GroupStorage()
12+
private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage")
13+
14+
private let groupsKey = "com.TablePro.groups"
15+
private let expandedGroupsKey = "com.TablePro.expandedGroups"
16+
private let defaults = UserDefaults.standard
17+
private let encoder = JSONEncoder()
18+
private let decoder = JSONDecoder()
19+
20+
private init() {}
21+
22+
// MARK: - Group CRUD
23+
24+
/// Load all groups
25+
func loadGroups() -> [ConnectionGroup] {
26+
guard let data = defaults.data(forKey: groupsKey) else {
27+
return []
28+
}
29+
30+
do {
31+
return try decoder.decode([ConnectionGroup].self, from: data)
32+
} catch {
33+
Self.logger.error("Failed to load groups: \(error)")
34+
return []
35+
}
36+
}
37+
38+
/// Save all groups
39+
func saveGroups(_ groups: [ConnectionGroup]) {
40+
do {
41+
let data = try encoder.encode(groups)
42+
defaults.set(data, forKey: groupsKey)
43+
} catch {
44+
Self.logger.error("Failed to save groups: \(error)")
45+
}
46+
}
47+
48+
/// Add a new group
49+
func addGroup(_ group: ConnectionGroup) {
50+
var groups = loadGroups()
51+
groups.append(group)
52+
saveGroups(groups)
53+
}
54+
55+
/// Update an existing group
56+
func updateGroup(_ group: ConnectionGroup) {
57+
var groups = loadGroups()
58+
if let index = groups.firstIndex(where: { $0.id == group.id }) {
59+
groups[index] = group
60+
saveGroups(groups)
61+
}
62+
}
63+
64+
/// Delete a group and all its descendants.
65+
/// Member connections become ungrouped.
66+
func deleteGroup(_ group: ConnectionGroup) {
67+
var groups = loadGroups()
68+
let deletedIds = collectDescendantIds(of: group.id, in: groups)
69+
let allDeletedIds = deletedIds.union([group.id])
70+
71+
// Remove deleted groups
72+
groups.removeAll { allDeletedIds.contains($0.id) }
73+
saveGroups(groups)
74+
75+
// Ungroup connections that belonged to deleted groups
76+
let storage = ConnectionStorage.shared
77+
var connections = storage.loadConnections()
78+
var changed = false
79+
for index in connections.indices {
80+
if let gid = connections[index].groupId, allDeletedIds.contains(gid) {
81+
connections[index].groupId = nil
82+
changed = true
83+
}
84+
}
85+
if changed {
86+
storage.saveConnections(connections)
87+
}
88+
}
89+
90+
/// Get group by ID
91+
func group(for id: UUID) -> ConnectionGroup? {
92+
loadGroups().first { $0.id == id }
93+
}
94+
95+
/// Get child groups of a parent, sorted by sortOrder
96+
func childGroups(of parentId: UUID?) -> [ConnectionGroup] {
97+
loadGroups()
98+
.filter { $0.parentGroupId == parentId }
99+
.sorted { $0.sortOrder < $1.sortOrder }
100+
}
101+
102+
/// Get the next sort order for a new item in a parent context
103+
func nextSortOrder(parentId: UUID?) -> Int {
104+
let siblings = loadGroups().filter { $0.parentGroupId == parentId }
105+
return (siblings.map(\.sortOrder).max() ?? -1) + 1
106+
}
107+
108+
// MARK: - Expanded State
109+
110+
/// Load the set of expanded group IDs
111+
func loadExpandedGroupIds() -> Set<UUID> {
112+
guard let data = defaults.data(forKey: expandedGroupsKey) else {
113+
return []
114+
}
115+
116+
do {
117+
let ids = try decoder.decode([UUID].self, from: data)
118+
return Set(ids)
119+
} catch {
120+
Self.logger.error("Failed to load expanded groups: \(error)")
121+
return []
122+
}
123+
}
124+
125+
/// Save the set of expanded group IDs
126+
func saveExpandedGroupIds(_ ids: Set<UUID>) {
127+
do {
128+
let data = try encoder.encode(Array(ids))
129+
defaults.set(data, forKey: expandedGroupsKey)
130+
} catch {
131+
Self.logger.error("Failed to save expanded groups: \(error)")
132+
}
133+
}
134+
135+
// MARK: - Helpers
136+
137+
/// Recursively collect all descendant group IDs
138+
private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set<UUID> {
139+
var result = Set<UUID>()
140+
let children = groups.filter { $0.parentGroupId == groupId }
141+
for child in children {
142+
result.insert(child.id)
143+
result.formUnion(collectDescendantIds(of: child.id, in: groups))
144+
}
145+
return result
146+
}
147+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// ConnectionGroup.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
/// A group for organizing database connections into folders
9+
struct ConnectionGroup: Identifiable, Hashable, Codable {
10+
let id: UUID
11+
var name: String
12+
var color: ConnectionColor
13+
var parentGroupId: UUID?
14+
var sortOrder: Int
15+
16+
init(
17+
id: UUID = UUID(),
18+
name: String,
19+
color: ConnectionColor = .blue,
20+
parentGroupId: UUID? = nil,
21+
sortOrder: Int = 0
22+
) {
23+
self.id = id
24+
self.name = name
25+
self.color = color
26+
self.parentGroupId = parentGroupId
27+
self.sortOrder = sortOrder
28+
}
29+
30+
// MARK: - Codable (Migration Support)
31+
32+
enum CodingKeys: String, CodingKey {
33+
case id, name, color, parentGroupId, sortOrder
34+
}
35+
36+
init(from decoder: Decoder) throws {
37+
let container = try decoder.container(keyedBy: CodingKeys.self)
38+
id = try container.decode(UUID.self, forKey: .id)
39+
name = try container.decode(String.self, forKey: .name)
40+
color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .blue
41+
parentGroupId = try container.decodeIfPresent(UUID.self, forKey: .parentGroupId)
42+
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
43+
}
44+
}

TablePro/Models/DatabaseConnection.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ struct DatabaseConnection: Identifiable, Hashable {
252252
var aiPolicy: AIConnectionPolicy?
253253
var mongoReadPreference: String?
254254
var mongoWriteConcern: String?
255+
var groupId: UUID?
256+
var sortOrder: Int
255257

256258
init(
257259
id: UUID = UUID(),
@@ -268,7 +270,9 @@ struct DatabaseConnection: Identifiable, Hashable {
268270
isReadOnly: Bool = false,
269271
aiPolicy: AIConnectionPolicy? = nil,
270272
mongoReadPreference: String? = nil,
271-
mongoWriteConcern: String? = nil
273+
mongoWriteConcern: String? = nil,
274+
groupId: UUID? = nil,
275+
sortOrder: Int = 0
272276
) {
273277
self.id = id
274278
self.name = name
@@ -285,6 +289,8 @@ struct DatabaseConnection: Identifiable, Hashable {
285289
self.aiPolicy = aiPolicy
286290
self.mongoReadPreference = mongoReadPreference
287291
self.mongoWriteConcern = mongoWriteConcern
292+
self.groupId = groupId
293+
self.sortOrder = sortOrder
288294
}
289295

290296
/// Returns the display color (custom color or database type color)

0 commit comments

Comments
 (0)