Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3ff40ec
feat: add connection groups
imhuytq Mar 2, 2026
4c5d8e2
update
imhuytq Mar 2, 2026
e00e14e
chore: update localization strings
imhuytq Mar 2, 2026
7aab5b9
fix
imhuytq Mar 4, 2026
1f961ca
Merge branch 'main' into feat/connection-groups
imhuytq Mar 4, 2026
1af3852
feat: support nested group selection in form sheet and context menus
imhuytq Mar 4, 2026
f7ef8ce
docs: add connection groups to changelog
imhuytq Mar 4, 2026
2fd698b
refactor: rename ConnectionGroupEditor to ConnectionGroupPicker, fix …
imhuytq Mar 4, 2026
b76144f
fix: address Copilot PR review comments and add backspace delete support
imhuytq Mar 6, 2026
2d2bfb7
feat: multi-selection, 2-step delete confirmation, and drag-drop impr…
imhuytq Mar 6, 2026
6c797af
Merge branch 'main' into feat/connection-groups
imhuytq Mar 6, 2026
1e9bb1d
docs: add dedicated download page with direct DMG links
datlechin Mar 6, 2026
334b5fa
fix: reset pbxproj to main and regenerate Localizable.xcstrings
imhuytq Mar 6, 2026
185d2af
Merge branch 'main' into feat/connection-groups
imhuytq Mar 6, 2026
deb5c35
fix: regenerate Localizable.xcstrings after merge
imhuytq Mar 6, 2026
4c71fc0
fix: address PR review issues in connection groups
datlechin Mar 6, 2026
f96489f
fix: sortOrder migration for legacy data, multi-drag position, and fo…
imhuytq Mar 6, 2026
864f32a
fix: group drag-drop position when reordering after last group
imhuytq Mar 6, 2026
bd1effe
fix: correct drag-drop reorder position when moving items forward
datlechin Mar 6, 2026
d311858
Merge remote-tracking branch 'origin/main' into feat/connection-groups
datlechin Mar 18, 2026
5169968
merge: remove stale PR files superseded by main's architecture
datlechin Mar 18, 2026
20e713e
merge: remove accidentally added submodules
datlechin Mar 18, 2026
6d14b40
merge: add sortOrder to DatabaseConnection, use main's ConnectionGrou…
datlechin Mar 18, 2026
6022d60
merge: fix sortOrder parameter ordering in DatabaseConnection init calls
datlechin Mar 18, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, and context menus for group and connection management

## [0.13.0] - 2026-03-04

### Added
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,9 @@ private struct StoredConnection: Codable {
// AI policy
let aiPolicy: String?

// Sort order
let sortOrder: Int

init(from connection: DatabaseConnection) {
self.id = connection.id
self.name = connection.name
Expand Down Expand Up @@ -392,6 +395,9 @@ private struct StoredConnection: Codable {

// AI policy
self.aiPolicy = connection.aiPolicy?.rawValue

// Sort order
self.sortOrder = connection.sortOrder
}

// Custom decoder to handle migration from old format
Expand Down Expand Up @@ -428,6 +434,7 @@ private struct StoredConnection: Codable {
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
Comment thread
datlechin marked this conversation as resolved.
}

func toConnection() -> DatabaseConnection {
Expand Down Expand Up @@ -467,7 +474,8 @@ private struct StoredConnection: Codable {
tagId: parsedTagId,
groupId: parsedGroupId,
isReadOnly: isReadOnly,
aiPolicy: parsedAIPolicy
aiPolicy: parsedAIPolicy,
sortOrder: sortOrder
)
}
}
80 changes: 75 additions & 5 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ final class GroupStorage {
private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage")

private let groupsKey = "com.TablePro.groups"
private let expandedGroupsKey = "com.TablePro.expandedGroups"
private let defaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
Expand Down Expand Up @@ -47,9 +48,6 @@ final class GroupStorage {
/// Add a new group
func addGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
Comment thread
datlechin marked this conversation as resolved.
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
return
}
groups.append(group)
saveGroups(groups)
}
Expand All @@ -63,15 +61,87 @@ final class GroupStorage {
}
}

/// Delete a group
/// Delete a group and all its descendants.
/// Member connections become ungrouped.
func deleteGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
groups.removeAll { $0.id == group.id }
let deletedIds = collectDescendantIds(of: group.id, in: groups)
let allDeletedIds = deletedIds.union([group.id])

// Remove deleted groups
groups.removeAll { allDeletedIds.contains($0.id) }
saveGroups(groups)

// Ungroup connections that belonged to deleted groups
let storage = ConnectionStorage.shared
var connections = storage.loadConnections()
var changed = false
for index in connections.indices {
if let gid = connections[index].groupId, allDeletedIds.contains(gid) {
connections[index].groupId = nil
changed = true
}
}
if changed {
storage.saveConnections(connections)
}
}

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

/// Get child groups of a parent, sorted by sortOrder
func childGroups(of parentId: UUID?) -> [ConnectionGroup] {
loadGroups()
.filter { $0.parentGroupId == parentId }
.sorted { $0.sortOrder < $1.sortOrder }
}

/// Get the next sort order for a new item in a parent context
func nextSortOrder(parentId: UUID?) -> Int {
let siblings = loadGroups().filter { $0.parentGroupId == parentId }
return (siblings.map(\.sortOrder).max() ?? -1) + 1
}

// MARK: - Expanded State

/// Load the set of expanded group IDs
func loadExpandedGroupIds() -> Set<UUID> {
guard let data = defaults.data(forKey: expandedGroupsKey) else {
return []
}

do {
let ids = try decoder.decode([UUID].self, from: data)
return Set(ids)
} catch {
Self.logger.error("Failed to load expanded groups: \(error)")
return []
}
}

/// Save the set of expanded group IDs
func saveExpandedGroupIds(_ ids: Set<UUID>) {
do {
let data = try encoder.encode(Array(ids))
defaults.set(data, forKey: expandedGroupsKey)
} catch {
Self.logger.error("Failed to save expanded groups: \(error)")
}
}
Comment thread
datlechin marked this conversation as resolved.

// MARK: - Helpers

/// Recursively collect all descendant group IDs
private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set<UUID> {
var result = Set<UUID>()
let children = groups.filter { $0.parentGroupId == groupId }
for child in children {
result.insert(child.id)
result.formUnion(collectDescendantIds(of: child.id, in: groups))
}
Comment on lines +177 to +183

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Descendant traversal needs cycle protection.

collectDescendantIds (Lines 177–183) recursively follows parent links with no visited-set guard. A malformed cycle can recurse indefinitely and crash delete/count flows.

🛡️ Suggested fix
-    func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set<UUID> {
-        var result = Set<UUID>()
-        let children = groups.filter { $0.parentGroupId == groupId }
-        for child in children {
-            result.insert(child.id)
-            result.formUnion(collectDescendantIds(of: child.id, in: groups))
-        }
-        return result
-    }
+    private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set<UUID> {
+        var visited: Set<UUID> = []
+        return collectDescendantIds(of: groupId, in: groups, visited: &visited)
+    }
+
+    private func collectDescendantIds(
+        of groupId: UUID,
+        in groups: [ConnectionGroup],
+        visited: inout Set<UUID>
+    ) -> Set<UUID> {
+        guard visited.insert(groupId).inserted else { return [] }
+        var result = Set<UUID>()
+        let children = groups.filter { $0.parentGroupId == groupId }
+        for child in children {
+            result.insert(child.id)
+            result.formUnion(collectDescendantIds(of: child.id, in: groups, visited: &visited))
+        }
+        return result
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TablePro/Core/Storage/GroupStorage.swift` around lines 177 - 183, The
recursive helper collectDescendantIds(of:in:) lacks cycle protection; modify it
to track visited nodes (e.g., add an inout Set<UUID> visited parameter or create
a private helper collectDescendantIds(of:in:visited:)) and on entry check/mark
the current groupId in visited so you skip already-seen IDs before filtering
children or recursing; ensure you still accumulate child IDs into result but
avoid recursing into a child whose id is in visited to prevent infinite
recursion on malformed cycles.

return result
}
}
29 changes: 27 additions & 2 deletions TablePro/Models/ConnectionGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,40 @@

import Foundation

/// A named group (folder) for organizing database connections
/// A group for organizing database connections into folders
struct ConnectionGroup: Identifiable, Hashable, Codable {
let id: UUID
var name: String
var color: ConnectionColor
var parentGroupId: UUID?
var sortOrder: Int

init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) {
init(
id: UUID = UUID(),
name: String,
color: ConnectionColor = .blue,
parentGroupId: UUID? = nil,
sortOrder: Int = 0
) {
self.id = id
self.name = name
self.color = color
self.parentGroupId = parentGroupId
self.sortOrder = sortOrder
}

// MARK: - Codable (Migration Support)

enum CodingKeys: String, CodingKey {
case id, name, color, parentGroupId, sortOrder
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .blue
parentGroupId = try container.decodeIfPresent(UUID.self, forKey: .parentGroupId)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
Comment thread
datlechin marked this conversation as resolved.
}
}
3 changes: 3 additions & 0 deletions TablePro/Models/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var aiPolicy: AIConnectionPolicy?
var mongoReadPreference: String?
var mongoWriteConcern: String?
var sortOrder: Int
var redisDatabase: Int?
Comment thread
datlechin marked this conversation as resolved.
Outdated

init(
Expand All @@ -280,6 +281,7 @@ struct DatabaseConnection: Identifiable, Hashable {
aiPolicy: AIConnectionPolicy? = nil,
mongoReadPreference: String? = nil,
mongoWriteConcern: String? = nil,
sortOrder: Int = 0,
redisDatabase: Int? = nil
) {
self.id = id
Expand All @@ -298,6 +300,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.aiPolicy = aiPolicy
self.mongoReadPreference = mongoReadPreference
self.mongoWriteConcern = mongoWriteConcern
self.sortOrder = sortOrder
self.redisDatabase = redisDatabase
}

Expand Down
Loading