Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 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
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

- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management

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 | 🟡 Minor

Move this entry to [Unreleased] instead of [0.14.1].

Line 239 documents a new feature in a historical release section. This should be listed under ## [Unreleased] (### Added) until release.

📘 Suggested changelog placement
 ## [Unreleased]
+
+### Added
+
+- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management

 ## [0.14.1] - 2026-03-06

 ### Added

-- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management
 - Add database and schema switching for PostgreSQL connections via ⌘K

As per coding guidelines: "Update CHANGELOG.md under [Unreleased] section (Added/Fixed/Changed) for new features and notable changes."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management
## [Unreleased]
### Added
- Connection groups: organize connections into named, color-coded folders with support for nested subgroups, drag-and-drop reordering, expand/collapse state persistence, multi-selection (bulk delete, bulk move to group), and context menus for group and connection management
## [0.14.1] - 2026-03-06
### Added
- Add database and schema switching for PostgreSQL connections via ⌘K
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 239, The changelog entry "Connection groups: organize
connections into named, color-coded folders with support for nested subgroups,
drag-and-drop reordering, expand/collapse state persistence, multi-selection
(bulk delete, bulk move to group), and context menus for group and connection
management" should be removed from the "## [0.14.1]" section and added under "##
[Unreleased]" inside the "### Added" subsection; locate that exact entry text in
CHANGELOG.md, cut it from the 0.14.1 section and paste it as a new bullet under
the Unreleased -> Added heading so the feature is tracked as unreleased.

- Add database and schema switching for PostgreSQL connections via ⌘K

## [0.14.0] - 2026-03-05
Expand Down
32 changes: 29 additions & 3 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,32 @@ final class ConnectionStorage {
username: connection.username,
type: connection.type,
sshConfig: connection.sshConfig,
sslConfig: connection.sslConfig,
color: connection.color,
tagId: connection.tagId,
groupId: connection.groupId
groupId: connection.groupId,
isReadOnly: connection.isReadOnly,
aiPolicy: connection.aiPolicy,
mongoReadPreference: connection.mongoReadPreference,
mongoWriteConcern: connection.mongoWriteConcern,
sortOrder: connection.sortOrder,
redisDatabase: connection.redisDatabase,
mssqlSchema: connection.mssqlSchema
)

// Save the duplicate connection
// Insert duplicate right after the original by shifting siblings
var connections = loadConnections()
connections.append(duplicate)
let newSortOrder = connection.sortOrder + 1
for index in connections.indices {
if connections[index].groupId == connection.groupId,
connections[index].sortOrder >= newSortOrder
{
connections[index].sortOrder += 1
}
}
var placed = duplicate
placed.sortOrder = newSortOrder
connections.append(placed)
saveConnections(connections)

// Copy all passwords from source to duplicate
Expand Down Expand Up @@ -358,6 +376,9 @@ private struct StoredConnection: Codable {
// AI policy
let aiPolicy: String?

// Sort order
let sortOrder: Int

// MSSQL schema
let mssqlSchema: String?

Expand Down Expand Up @@ -396,6 +417,9 @@ private struct StoredConnection: Codable {
// AI policy
self.aiPolicy = connection.aiPolicy?.rawValue

// Sort order
self.sortOrder = connection.sortOrder

// MSSQL schema
self.mssqlSchema = connection.mssqlSchema
}
Expand Down Expand Up @@ -434,6 +458,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.
mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema)
}

Expand Down Expand Up @@ -475,6 +500,7 @@ private struct StoredConnection: Codable {
groupId: parsedGroupId,
isReadOnly: isReadOnly,
aiPolicy: parsedAIPolicy,
sortOrder: sortOrder,
mssqlSchema: mssqlSchema
)
}
Expand Down
95 changes: 91 additions & 4 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 @@ -44,10 +45,11 @@ final class GroupStorage {
}
}

/// Add a new group
/// Add a new group (rejects case-insensitive duplicate names)
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 {
if groups.contains(where: { $0.name.caseInsensitiveCompare(group.name) == .orderedSame }) {
Self.logger.debug("Ignoring attempt to add duplicate group name: \(group.name, privacy: .public)")
return
}
groups.append(group)
Expand All @@ -63,15 +65,100 @@ final class GroupStorage {
}
}

/// Delete a group
/// Delete a group and all its descendants, including their connections.
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)

// Delete connections that belonged to deleted groups
let storage = ConnectionStorage.shared
let connections = storage.loadConnections()
var remaining: [DatabaseConnection] = []
for conn in connections {
if let gid = conn.groupId, allDeletedIds.contains(gid) {
// Clean up keychain entries
storage.deletePassword(for: conn.id)
storage.deleteSSHPassword(for: conn.id)
storage.deleteKeyPassphrase(for: conn.id)
} else {
remaining.append(conn)
}
}
storage.saveConnections(remaining)
Comment thread
datlechin marked this conversation as resolved.
}
Comment on lines +100 to +115

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

Group deletion misses connection tombstones and TOTP keychain cleanup.

In Lines 105–114, deleted connections are removed from persisted data, but:

  • SyncChangeTracker is not notified for each deleted connection.
  • deleteTOTPSecret(for:) is not called.

This can leave stale synced records and orphaned secrets.

🛠️ Suggested fix
         for conn in connections {
             if let gid = conn.groupId, allDeletedIds.contains(gid) {
+                SyncChangeTracker.shared.markDeleted(.connection, id: conn.id.uuidString)
                 // Clean up keychain entries
                 storage.deletePassword(for: conn.id)
                 storage.deleteSSHPassword(for: conn.id)
                 storage.deleteKeyPassphrase(for: conn.id)
+                storage.deleteTOTPSecret(for: conn.id)
             } else {
                 remaining.append(conn)
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Delete connections that belonged to deleted groups
let storage = ConnectionStorage.shared
let connections = storage.loadConnections()
var remaining: [DatabaseConnection] = []
for conn in connections {
if let gid = conn.groupId, allDeletedIds.contains(gid) {
// Clean up keychain entries
storage.deletePassword(for: conn.id)
storage.deleteSSHPassword(for: conn.id)
storage.deleteKeyPassphrase(for: conn.id)
} else {
remaining.append(conn)
}
}
storage.saveConnections(remaining)
}
// Delete connections that belonged to deleted groups
let storage = ConnectionStorage.shared
let connections = storage.loadConnections()
var remaining: [DatabaseConnection] = []
for conn in connections {
if let gid = conn.groupId, allDeletedIds.contains(gid) {
SyncChangeTracker.shared.markDeleted(.connection, id: conn.id.uuidString)
// Clean up keychain entries
storage.deletePassword(for: conn.id)
storage.deleteSSHPassword(for: conn.id)
storage.deleteKeyPassphrase(for: conn.id)
storage.deleteTOTPSecret(for: conn.id)
} else {
remaining.append(conn)
}
}
storage.saveConnections(remaining)
}
🤖 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 100 - 115, When
removing connections in the Group deletion loop, ensure you both clean TOTP
secrets and record deletions with the sync tracker: for each conn where you
currently call storage.deletePassword(for:), storage.deleteSSHPassword(for:) and
storage.deleteKeyPassphrase(for:), also call storage.deleteTOTPSecret(for:
conn.id) and notify the sync system (e.g.
SyncChangeTracker.shared.recordDeletion(for: conn.id) or the appropriate method
on SyncChangeTracker) so a tombstone/change is recorded before calling
storage.saveConnections(remaining).


/// Count all connections inside a group and its descendants.
func connectionCount(for group: ConnectionGroup) -> Int {
let allGroups = loadGroups()
let descendantIds = collectDescendantIds(of: group.id, in: allGroups)
let allGroupIds = descendantIds.union([group.id])
let connections = ConnectionStorage.shared.loadConnections()
return connections.filter { conn in
guard let gid = conn.groupId else { return false }
return allGroupIds.contains(gid)
}.count
}

/// 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 @@ -272,6 +272,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
var mssqlSchema: String?

Expand All @@ -292,6 +293,7 @@ struct DatabaseConnection: Identifiable, Hashable {
aiPolicy: AIConnectionPolicy? = nil,
mongoReadPreference: String? = nil,
mongoWriteConcern: String? = nil,
sortOrder: Int = 0,
redisDatabase: Int? = nil,
mssqlSchema: String? = nil
) {
Expand All @@ -311,6 +313,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.aiPolicy = aiPolicy
self.mongoReadPreference = mongoReadPreference
self.mongoWriteConcern = mongoWriteConcern
self.sortOrder = sortOrder
self.redisDatabase = redisDatabase
self.mssqlSchema = mssqlSchema
}
Expand Down
Loading
Loading