Skip to content

Commit 04569c2

Browse files
committed
feat: add nested hierarchical groups for connection list (#478)
1 parent e676cdc commit 04569c2

File tree

10 files changed

+1141
-272
lines changed

10 files changed

+1141
-272
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Nested hierarchical groups for connection list (up to 3 levels deep) with subgroup creation, group reparenting, and recursive delete
1213
- Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts
1314
- JSON fields in Row Details sidebar now display in a scrollable monospaced text area
1415

TablePro/Core/Storage/GroupStorage.swift

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ final class GroupStorage {
5353
}
5454
}
5555

56-
/// Add a new group
56+
/// Add a new group (duplicate check scoped to siblings with same parentId)
5757
func addGroup(_ group: ConnectionGroup) {
5858
var groups = loadGroups()
59-
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
59+
let siblings = groups.filter { $0.parentId == group.parentId }
60+
guard !siblings.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
6061
return
6162
}
6263
groups.append(group)
@@ -72,16 +73,43 @@ final class GroupStorage {
7273
}
7374
}
7475

75-
/// Delete a group
76+
/// Delete a group and all descendant groups, nil-out groupId on affected connections
7677
func deleteGroup(_ group: ConnectionGroup) {
77-
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
7878
var groups = loadGroups()
79-
groups.removeAll { $0.id == group.id }
79+
let descendantIds = collectAllDescendantGroupIds(groupId: group.id, groups: groups)
80+
let allIdsToDelete = descendantIds.union([group.id])
81+
82+
for deletedId in allIdsToDelete {
83+
SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString)
84+
}
85+
86+
groups.removeAll { allIdsToDelete.contains($0.id) }
8087
saveGroups(groups)
88+
89+
let storage = ConnectionStorage.shared
90+
var connections = storage.loadConnections()
91+
var changed = false
92+
for i in connections.indices {
93+
if let gid = connections[i].groupId, allIdsToDelete.contains(gid) {
94+
connections[i].groupId = nil
95+
changed = true
96+
}
97+
}
98+
if changed {
99+
storage.saveConnections(connections)
100+
}
81101
}
82102

83103
/// Get group by ID
84104
func group(for id: UUID) -> ConnectionGroup? {
85105
loadGroups().first { $0.id == id }
86106
}
107+
108+
/// Validate that adding a child under parentId would not exceed max depth
109+
func validateDepth(parentId: UUID?, maxDepth: Int = 3) -> Bool {
110+
guard let pid = parentId else { return true }
111+
let groups = loadGroups()
112+
let parentDepth = depthOf(groupId: pid, groups: groups)
113+
return parentDepth < maxDepth
114+
}
87115
}

TablePro/Core/Sync/SyncRecordMapper.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ struct SyncRecordMapper {
176176
record["groupId"] = group.id.uuidString as CKRecordValue
177177
record["name"] = group.name as CKRecordValue
178178
record["color"] = group.color.rawValue as CKRecordValue
179+
record["parentId"] = group.parentId?.uuidString as CKRecordValue? ?? ("" as CKRecordValue)
180+
record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue
179181
record["modifiedAtLocal"] = Date() as CKRecordValue
180182
record["schemaVersion"] = schemaVersion as CKRecordValue
181183

@@ -192,11 +194,15 @@ struct SyncRecordMapper {
192194
}
193195

194196
let colorRaw = record["color"] as? String ?? ConnectionColor.none.rawValue
197+
let parentId = (record["parentId"] as? String).flatMap { UUID(uuidString: $0) }
198+
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
195199

196200
return ConnectionGroup(
197201
id: groupId,
198202
name: name,
199-
color: ConnectionColor(rawValue: colorRaw) ?? .none
203+
color: ConnectionColor(rawValue: colorRaw) ?? .none,
204+
parentId: parentId,
205+
sortOrder: sortOrder
200206
)
201207
}
202208

TablePro/Models/Connection/ConnectionGroup.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ struct ConnectionGroup: Identifiable, Hashable, Codable {
1010
let id: UUID
1111
var name: String
1212
var color: ConnectionColor
13+
var parentId: UUID?
14+
var sortOrder: Int
1315

14-
init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) {
16+
init(id: UUID = UUID(), name: String, color: ConnectionColor = .none, parentId: UUID? = nil, sortOrder: Int = 0) {
1517
self.id = id
1618
self.name = name
1719
self.color = color
20+
self.parentId = parentId
21+
self.sortOrder = sortOrder
1822
}
1923
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//
2+
// ConnectionGroupTree.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
enum ConnectionGroupTreeNode: Identifiable, Hashable {
9+
case group(ConnectionGroup, children: [ConnectionGroupTreeNode])
10+
case connection(DatabaseConnection)
11+
12+
var id: String {
13+
switch self {
14+
case .group(let g, _): "group-\(g.id)"
15+
case .connection(let c): "conn-\(c.id)"
16+
}
17+
}
18+
19+
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
20+
func hash(into hasher: inout Hasher) { hasher.combine(id) }
21+
}
22+
23+
// MARK: - Tree Building
24+
25+
func buildGroupTree(
26+
groups: [ConnectionGroup],
27+
connections: [DatabaseConnection],
28+
parentId: UUID?,
29+
maxDepth: Int = 3,
30+
currentDepth: Int = 0
31+
) -> [ConnectionGroupTreeNode] {
32+
var items: [ConnectionGroupTreeNode] = []
33+
34+
let validGroupIds = Set(groups.map(\.id))
35+
36+
let levelGroups: [ConnectionGroup]
37+
if parentId == nil {
38+
levelGroups = groups
39+
.filter { $0.parentId == nil || !validGroupIds.contains($0.parentId!) }
40+
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
41+
} else {
42+
levelGroups = groups
43+
.filter { $0.parentId == parentId }
44+
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
45+
}
46+
47+
for group in levelGroups {
48+
var children: [ConnectionGroupTreeNode] = []
49+
if currentDepth < maxDepth {
50+
children = buildGroupTree(
51+
groups: groups,
52+
connections: connections,
53+
parentId: group.id,
54+
maxDepth: maxDepth,
55+
currentDepth: currentDepth + 1
56+
)
57+
}
58+
59+
let groupConnections = connections
60+
.filter { $0.groupId == group.id }
61+
for conn in groupConnections {
62+
children.append(.connection(conn))
63+
}
64+
65+
items.append(.group(group, children: children))
66+
}
67+
68+
if parentId == nil {
69+
let ungrouped = connections.filter { conn in
70+
guard let groupId = conn.groupId else { return true }
71+
return !validGroupIds.contains(groupId)
72+
}
73+
for conn in ungrouped {
74+
items.append(.connection(conn))
75+
}
76+
}
77+
78+
return items
79+
}
80+
81+
// MARK: - Tree Filtering
82+
83+
func filterGroupTree(_ items: [ConnectionGroupTreeNode], searchText: String) -> [ConnectionGroupTreeNode] {
84+
guard !searchText.isEmpty else { return items }
85+
86+
return items.compactMap { item in
87+
switch item {
88+
case .connection(let conn):
89+
if conn.name.localizedCaseInsensitiveContains(searchText)
90+
|| conn.host.localizedCaseInsensitiveContains(searchText)
91+
|| conn.database.localizedCaseInsensitiveContains(searchText) {
92+
return item
93+
}
94+
return nil
95+
case .group(let group, let children):
96+
if group.name.localizedCaseInsensitiveContains(searchText) {
97+
return item
98+
}
99+
let filteredChildren = filterGroupTree(children, searchText: searchText)
100+
if !filteredChildren.isEmpty {
101+
return .group(group, children: filteredChildren)
102+
}
103+
return nil
104+
}
105+
}
106+
}
107+
108+
// MARK: - Tree Traversal
109+
110+
func flattenVisibleConnections(
111+
tree: [ConnectionGroupTreeNode],
112+
expandedGroupIds: Set<UUID>
113+
) -> [DatabaseConnection] {
114+
var result: [DatabaseConnection] = []
115+
for item in tree {
116+
switch item {
117+
case .connection(let conn):
118+
result.append(conn)
119+
case .group(let group, let children):
120+
if expandedGroupIds.contains(group.id) {
121+
result.append(contentsOf: flattenVisibleConnections(tree: children, expandedGroupIds: expandedGroupIds))
122+
}
123+
}
124+
}
125+
return result
126+
}
127+
128+
func collectAllDescendantGroupIds(groupId: UUID, groups: [ConnectionGroup]) -> Set<UUID> {
129+
var result = Set<UUID>()
130+
let directChildren = groups.filter { $0.parentId == groupId }
131+
for child in directChildren {
132+
result.insert(child.id)
133+
result.formUnion(collectAllDescendantGroupIds(groupId: child.id, groups: groups))
134+
}
135+
return result
136+
}
137+
138+
func wouldCreateCircle(movingGroupId: UUID, toParentId: UUID?, groups: [ConnectionGroup]) -> Bool {
139+
guard let targetId = toParentId else { return false }
140+
if targetId == movingGroupId { return true }
141+
let descendants = collectAllDescendantGroupIds(groupId: movingGroupId, groups: groups)
142+
return descendants.contains(targetId)
143+
}
144+
145+
func depthOf(groupId: UUID?, groups: [ConnectionGroup]) -> Int {
146+
guard let gid = groupId else { return 0 }
147+
guard let group = groups.first(where: { $0.id == gid }) else { return 0 }
148+
return 1 + depthOf(groupId: group.parentId, groups: groups)
149+
}
150+
151+
func maxDescendantDepth(groupId: UUID, groups: [ConnectionGroup]) -> Int {
152+
let children = groups.filter { $0.parentId == groupId }
153+
if children.isEmpty { return 0 }
154+
return 1 + children.map { maxDescendantDepth(groupId: $0.id, groups: groups) }.max()!
155+
}
156+
157+
func connectionCount(in groupId: UUID, connections: [DatabaseConnection], groups: [ConnectionGroup]) -> Int {
158+
let directCount = connections.filter { $0.groupId == groupId }.count
159+
let descendants = collectAllDescendantGroupIds(groupId: groupId, groups: groups)
160+
let descendantCount = connections.filter { conn in
161+
guard let gid = conn.groupId else { return false }
162+
return descendants.contains(gid)
163+
}.count
164+
return directCount + descendantCount
165+
}

0 commit comments

Comments
 (0)