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

Filter by extension

Filter by extension

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

### Added

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

Expand Down
51 changes: 42 additions & 9 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,35 +53,68 @@ final class GroupStorage {
}
}

/// Add a new group
/// Add a new group (duplicate check scoped to siblings, enforces depth cap and cycle prevention)
func addGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
guard !wouldCreateCircle(movingGroupId: group.id, toParentId: group.parentId, groups: groups) else { return }
guard validateDepth(parentId: group.parentId) else { return }
let siblings = groups.filter { $0.parentId == group.parentId }
guard !siblings.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
return
}
groups.append(group)
saveGroups(groups)
}

/// Update an existing group
/// Update an existing group (enforces cycle prevention and depth cap on parentId changes)
func updateGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index] = group
saveGroups(groups)
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
if group.parentId != groups[index].parentId {
guard !wouldCreateCircle(movingGroupId: group.id, toParentId: group.parentId, groups: groups) else { return }
guard validateDepth(parentId: group.parentId) else { return }
}
groups[index] = group
saveGroups(groups)
}

/// Delete a group
/// Delete a group and all descendant groups, nil-out groupId on affected connections
func deleteGroup(_ group: ConnectionGroup) {
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
var groups = loadGroups()
groups.removeAll { $0.id == group.id }
let descendantIds = collectAllDescendantGroupIds(groupId: group.id, groups: groups)
let allIdsToDelete = descendantIds.union([group.id])

for deletedId in allIdsToDelete {
SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString)
}

groups.removeAll { allIdsToDelete.contains($0.id) }
saveGroups(groups)

let storage = ConnectionStorage.shared
var connections = storage.loadConnections()
var changed = false
for i in connections.indices {
if let gid = connections[i].groupId, allIdsToDelete.contains(gid) {
connections[i].groupId = nil
changed = true
}
}
if changed {
storage.saveConnections(connections)
}
}

/// Get group by ID
func group(for id: UUID) -> ConnectionGroup? {
loadGroups().first { $0.id == id }
}

/// Validate that adding a child under parentId would not exceed max depth
func validateDepth(parentId: UUID?, maxDepth: Int = 3) -> Bool {
guard let pid = parentId else { return true }
let groups = loadGroups()
let parentDepth = depthOf(groupId: pid, groups: groups)
return parentDepth < maxDepth
}
}
10 changes: 9 additions & 1 deletion TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ struct SyncRecordMapper {
record["groupId"] = group.id.uuidString as CKRecordValue
record["name"] = group.name as CKRecordValue
record["color"] = group.color.rawValue as CKRecordValue
if let parentId = group.parentId {
record["parentId"] = parentId.uuidString as CKRecordValue
}
record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
record["schemaVersion"] = schemaVersion as CKRecordValue

Expand All @@ -192,11 +196,15 @@ struct SyncRecordMapper {
}

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

return ConnectionGroup(
id: groupId,
name: name,
color: ConnectionColor(rawValue: colorRaw) ?? .none
color: ConnectionColor(rawValue: colorRaw) ?? .none,
parentId: parentId,
sortOrder: sortOrder
)
}

Expand Down
6 changes: 5 additions & 1 deletion TablePro/Models/Connection/ConnectionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ struct ConnectionGroup: Identifiable, Hashable, Codable {
let id: UUID
var name: String
var color: ConnectionColor
var parentId: UUID?
var sortOrder: Int

init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) {
init(id: UUID = UUID(), name: String, color: ConnectionColor = .none, parentId: UUID? = nil, sortOrder: Int = 0) {
self.id = id
self.name = name
self.color = color
self.parentId = parentId
self.sortOrder = sortOrder
}
}
166 changes: 166 additions & 0 deletions TablePro/Models/Connection/ConnectionGroupTree.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// ConnectionGroupTree.swift
// TablePro
//

import Foundation

enum ConnectionGroupTreeNode: Identifiable, Hashable {
case group(ConnectionGroup, children: [ConnectionGroupTreeNode])
case connection(DatabaseConnection)

var id: String {
switch self {
case .group(let g, _): "group-\(g.id)"
case .connection(let c): "conn-\(c.id)"
}
}

static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}

// MARK: - Tree Building

func buildGroupTree(
groups: [ConnectionGroup],
connections: [DatabaseConnection],
parentId: UUID?,
maxDepth: Int = 3,
currentDepth: Int = 0
) -> [ConnectionGroupTreeNode] {
var items: [ConnectionGroupTreeNode] = []

let validGroupIds = Set(groups.map(\.id))

let levelGroups: [ConnectionGroup]
if parentId == nil {
levelGroups = groups
.filter { $0.parentId == nil || !validGroupIds.contains($0.parentId!) }
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
} else {
levelGroups = groups
.filter { $0.parentId == parentId }
.sorted { $0.sortOrder != $1.sortOrder ? $0.sortOrder < $1.sortOrder : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}

for group in levelGroups {
var children: [ConnectionGroupTreeNode] = []
if currentDepth < maxDepth {
children = buildGroupTree(
groups: groups,
connections: connections,
parentId: group.id,
maxDepth: maxDepth,
currentDepth: currentDepth + 1
)
}

let groupConnections = connections
.filter { $0.groupId == group.id }
for conn in groupConnections {
children.append(.connection(conn))
}

items.append(.group(group, children: children))
}

if parentId == nil {
let ungrouped = connections.filter { conn in
guard let groupId = conn.groupId else { return true }
return !validGroupIds.contains(groupId)
}
for conn in ungrouped {
items.append(.connection(conn))
}
}

return items
}

// MARK: - Tree Filtering

func filterGroupTree(_ items: [ConnectionGroupTreeNode], searchText: String) -> [ConnectionGroupTreeNode] {
guard !searchText.isEmpty else { return items }

return items.compactMap { item in
switch item {
case .connection(let conn):
if conn.name.localizedCaseInsensitiveContains(searchText)
|| conn.host.localizedCaseInsensitiveContains(searchText)
|| conn.database.localizedCaseInsensitiveContains(searchText) {
return item
}
return nil
case .group(let group, let children):
if group.name.localizedCaseInsensitiveContains(searchText) {
return item
}
let filteredChildren = filterGroupTree(children, searchText: searchText)
if !filteredChildren.isEmpty {
return .group(group, children: filteredChildren)
}
return nil
}
}
}

// MARK: - Tree Traversal

func flattenVisibleConnections(
tree: [ConnectionGroupTreeNode],
expandedGroupIds: Set<UUID>
) -> [DatabaseConnection] {
var result: [DatabaseConnection] = []
for item in tree {
switch item {
case .connection(let conn):
result.append(conn)
case .group(let group, let children):
if expandedGroupIds.contains(group.id) {
result.append(contentsOf: flattenVisibleConnections(tree: children, expandedGroupIds: expandedGroupIds))
}
}
}
return result
}

func collectAllDescendantGroupIds(groupId: UUID, groups: [ConnectionGroup], visited: Set<UUID> = []) -> Set<UUID> {
var result = Set<UUID>()
let directChildren = groups.filter { $0.parentId == groupId }
for child in directChildren where !visited.contains(child.id) {
result.insert(child.id)
result.formUnion(collectAllDescendantGroupIds(groupId: child.id, groups: groups, visited: visited.union(result).union([groupId])))
}
return result
}

func wouldCreateCircle(movingGroupId: UUID, toParentId: UUID?, groups: [ConnectionGroup]) -> Bool {
guard let targetId = toParentId else { return false }
if targetId == movingGroupId { return true }
let descendants = collectAllDescendantGroupIds(groupId: movingGroupId, groups: groups)
return descendants.contains(targetId)
}

func depthOf(groupId: UUID?, groups: [ConnectionGroup], visited: Set<UUID> = []) -> Int {
guard let gid = groupId else { return 0 }
guard !visited.contains(gid) else { return 0 }
guard let group = groups.first(where: { $0.id == gid }) else { return 0 }
return 1 + depthOf(groupId: group.parentId, groups: groups, visited: visited.union([gid]))
}

func maxDescendantDepth(groupId: UUID, groups: [ConnectionGroup]) -> Int {
let children = groups.filter { $0.parentId == groupId }
if children.isEmpty { return 0 }
return 1 + children.map { maxDescendantDepth(groupId: $0.id, groups: groups) }.max()!
}

func connectionCount(in groupId: UUID, connections: [DatabaseConnection], groups: [ConnectionGroup]) -> Int {
let directCount = connections.filter { $0.groupId == groupId }.count
let descendants = collectAllDescendantGroupIds(groupId: groupId, groups: groups)
let descendantCount = connections.filter { conn in
guard let gid = conn.groupId else { return false }
return descendants.contains(gid)
}.count
return directCount + descendantCount
}
Loading
Loading