Skip to content

Commit 847bc26

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

10 files changed

Lines changed: 1150 additions & 277 deletions

File tree

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: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,35 +53,68 @@ final class GroupStorage {
5353
}
5454
}
5555

56-
/// Add a new group
56+
/// Add a new group (duplicate check scoped to siblings, enforces depth cap and cycle prevention)
5757
func addGroup(_ group: ConnectionGroup) {
5858
var groups = loadGroups()
59-
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
59+
guard !wouldCreateCircle(movingGroupId: group.id, toParentId: group.parentId, groups: groups) else { return }
60+
guard validateDepth(parentId: group.parentId) else { return }
61+
let siblings = groups.filter { $0.parentId == group.parentId }
62+
guard !siblings.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
6063
return
6164
}
6265
groups.append(group)
6366
saveGroups(groups)
6467
}
6568

66-
/// Update an existing group
69+
/// Update an existing group (enforces cycle prevention and depth cap on parentId changes)
6770
func updateGroup(_ group: ConnectionGroup) {
6871
var groups = loadGroups()
69-
if let index = groups.firstIndex(where: { $0.id == group.id }) {
70-
groups[index] = group
71-
saveGroups(groups)
72+
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
73+
if group.parentId != groups[index].parentId {
74+
guard !wouldCreateCircle(movingGroupId: group.id, toParentId: group.parentId, groups: groups) else { return }
75+
guard validateDepth(parentId: group.parentId) else { return }
7276
}
77+
groups[index] = group
78+
saveGroups(groups)
7379
}
7480

75-
/// Delete a group
81+
/// Delete a group and all descendant groups, nil-out groupId on affected connections
7682
func deleteGroup(_ group: ConnectionGroup) {
77-
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
7883
var groups = loadGroups()
79-
groups.removeAll { $0.id == group.id }
84+
let descendantIds = collectAllDescendantGroupIds(groupId: group.id, groups: groups)
85+
let allIdsToDelete = descendantIds.union([group.id])
86+
87+
for deletedId in allIdsToDelete {
88+
SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString)
89+
}
90+
91+
groups.removeAll { allIdsToDelete.contains($0.id) }
8092
saveGroups(groups)
93+
94+
let storage = ConnectionStorage.shared
95+
var connections = storage.loadConnections()
96+
var changed = false
97+
for i in connections.indices {
98+
if let gid = connections[i].groupId, allIdsToDelete.contains(gid) {
99+
connections[i].groupId = nil
100+
changed = true
101+
}
102+
}
103+
if changed {
104+
storage.saveConnections(connections)
105+
}
81106
}
82107

83108
/// Get group by ID
84109
func group(for id: UUID) -> ConnectionGroup? {
85110
loadGroups().first { $0.id == id }
86111
}
112+
113+
/// Validate that adding a child under parentId would not exceed max depth
114+
func validateDepth(parentId: UUID?, maxDepth: Int = 3) -> Bool {
115+
guard let pid = parentId else { return true }
116+
let groups = loadGroups()
117+
let parentDepth = depthOf(groupId: pid, groups: groups)
118+
return parentDepth < maxDepth
119+
}
87120
}

TablePro/Core/Sync/SyncRecordMapper.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ 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+
if let parentId = group.parentId {
180+
record["parentId"] = parentId.uuidString as CKRecordValue
181+
}
182+
record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue
179183
record["modifiedAtLocal"] = Date() as CKRecordValue
180184
record["schemaVersion"] = schemaVersion as CKRecordValue
181185

@@ -192,11 +196,15 @@ struct SyncRecordMapper {
192196
}
193197

194198
let colorRaw = record["color"] as? String ?? ConnectionColor.none.rawValue
199+
let parentId = (record["parentId"] as? String).flatMap { UUID(uuidString: $0) }
200+
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
195201

196202
return ConnectionGroup(
197203
id: groupId,
198204
name: name,
199-
color: ConnectionColor(rawValue: colorRaw) ?? .none
205+
color: ConnectionColor(rawValue: colorRaw) ?? .none,
206+
parentId: parentId,
207+
sortOrder: sortOrder
200208
)
201209
}
202210

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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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], visited: Set<UUID> = []) -> Set<UUID> {
129+
var result = Set<UUID>()
130+
let directChildren = groups.filter { $0.parentId == groupId }
131+
for child in directChildren where !visited.contains(child.id) {
132+
result.insert(child.id)
133+
result.formUnion(collectAllDescendantGroupIds(groupId: child.id, groups: groups, visited: visited.union(result).union([groupId])))
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], visited: Set<UUID> = []) -> Int {
146+
guard let gid = groupId else { return 0 }
147+
guard !visited.contains(gid) else { return 0 }
148+
guard let group = groups.first(where: { $0.id == gid }) else { return 0 }
149+
return 1 + depthOf(groupId: group.parentId, groups: groups, visited: visited.union([gid]))
150+
}
151+
152+
func maxDescendantDepth(groupId: UUID, groups: [ConnectionGroup]) -> Int {
153+
let children = groups.filter { $0.parentId == groupId }
154+
if children.isEmpty { return 0 }
155+
return 1 + children.map { maxDescendantDepth(groupId: $0.id, groups: groups) }.max()!
156+
}
157+
158+
func connectionCount(in groupId: UUID, connections: [DatabaseConnection], groups: [ConnectionGroup]) -> Int {
159+
let directCount = connections.filter { $0.groupId == groupId }.count
160+
let descendants = collectAllDescendantGroupIds(groupId: groupId, groups: groups)
161+
let descendantCount = connections.filter { conn in
162+
guard let gid = conn.groupId else { return false }
163+
return descendants.contains(gid)
164+
}.count
165+
return directCount + descendantCount
166+
}

0 commit comments

Comments
 (0)