Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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 @@ -236,6 +236,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

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
45 changes: 40 additions & 5 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ final class ConnectionStorage {
do {
let storedConnections = try decoder.decode([StoredConnection].self, from: data)

let connections = storedConnections.map { stored in
var connections = storedConnections.map { stored in
stored.toConnection()
}
migrateSortOrderIfNeeded(&connections)
cachedConnections = connections
return connections
} catch {
Expand All @@ -61,6 +62,17 @@ final class ConnectionStorage {
}
}

/// Assign sequential sortOrder when all items have default 0 (legacy migration).
private func migrateSortOrderIfNeeded(_ connections: inout [DatabaseConnection]) {
guard connections.count > 1, connections.allSatisfy({ $0.sortOrder == 0 }) else { return }
for index in connections.indices {
connections[index].sortOrder = index
}
saveConnections(connections)
let count = connections.count
Self.logger.info("Migrated sortOrder for \(count) connections")
}

/// Add a new connection
func addConnection(_ connection: DatabaseConnection, password: String? = nil) {
var connections = loadConnections()
Expand Down Expand Up @@ -122,16 +134,27 @@ final class ConnectionStorage {
color: connection.color,
tagId: connection.tagId,
groupId: connection.groupId,
sortOrder: connection.sortOrder,
safeModeLevel: connection.safeModeLevel,
aiPolicy: connection.aiPolicy,
redisDatabase: connection.redisDatabase,
startupCommands: connection.startupCommands,
additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields
)

// 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)
SyncChangeTracker.shared.markDirty(.connection, id: duplicate.id.uuidString)

Expand All @@ -149,7 +172,7 @@ final class ConnectionStorage {
saveTOTPSecret(totpSecret, for: newId)
}

return duplicate
return placed
}

// MARK: - Keychain (Password Storage)
Expand Down Expand Up @@ -266,6 +289,9 @@ private struct StoredConnection: Codable {
// AI policy
let aiPolicy: String?

// Sort order
let sortOrder: Int

// MongoDB-specific
let mongoAuthSource: String?
let mongoReadPreference: String?
Expand Down Expand Up @@ -334,6 +360,9 @@ private struct StoredConnection: Codable {
// AI policy
self.aiPolicy = connection.aiPolicy?.rawValue

// Sort order
self.sortOrder = connection.sortOrder

// MongoDB-specific
self.mongoAuthSource = connection.mongoAuthSource
self.mongoReadPreference = connection.mongoReadPreference
Expand Down Expand Up @@ -364,7 +393,7 @@ private struct StoredConnection: Codable {
case color, tagId, groupId
case safeModeLevel
case isReadOnly // Legacy key for migration reading only
case aiPolicy
case aiPolicy, sortOrder
case mongoAuthSource, mongoReadPreference, mongoWriteConcern, redisDatabase
case mssqlSchema, oracleServiceName, startupCommands
case additionalFields
Expand Down Expand Up @@ -400,6 +429,10 @@ private struct StoredConnection: Codable {
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
try container.encodeIfPresent(aiPolicy, forKey: .aiPolicy)
try container.encode(sortOrder, forKey: .sortOrder)
try container.encodeIfPresent(mongoAuthSource, forKey: .mongoAuthSource)
try container.encodeIfPresent(mongoReadPreference, forKey: .mongoReadPreference)
try container.encodeIfPresent(mongoWriteConcern, forKey: .mongoWriteConcern)
try container.encodeIfPresent(redisDatabase, forKey: .redisDatabase)
try container.encodeIfPresent(startupCommands, forKey: .startupCommands)
try container.encodeIfPresent(additionalFields, forKey: .additionalFields)
Expand Down Expand Up @@ -456,6 +489,7 @@ private struct StoredConnection: Codable {
safeModeLevel = wasReadOnly ? SafeModeLevel.readOnly.rawValue : SafeModeLevel.silent.rawValue
}
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
mongoAuthSource = try container.decodeIfPresent(String.self, forKey: .mongoAuthSource)
mongoReadPreference = try container.decodeIfPresent(String.self, forKey: .mongoReadPreference)
mongoWriteConcern = try container.decodeIfPresent(String.self, forKey: .mongoWriteConcern)
Expand Down Expand Up @@ -524,6 +558,7 @@ private struct StoredConnection: Codable {
color: parsedColor,
tagId: parsedTagId,
groupId: parsedGroupId,
sortOrder: sortOrder,
safeModeLevel: SafeModeLevel(rawValue: safeModeLevel) ?? .silent,
aiPolicy: parsedAIPolicy,
redisDatabase: redisDatabase,
Expand Down
121 changes: 114 additions & 7 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 All @@ -27,13 +28,26 @@ final class GroupStorage {
}

do {
return try decoder.decode([ConnectionGroup].self, from: data)
var groups = try decoder.decode([ConnectionGroup].self, from: data)
migrateSortOrderIfNeeded(&groups)
return groups
} catch {
Self.logger.error("Failed to load groups: \(error)")
return []
}
}

/// Assign sequential sortOrder when all items have default 0 (legacy migration).
private func migrateSortOrderIfNeeded(_ groups: inout [ConnectionGroup]) {
guard groups.count > 1, groups.allSatisfy({ $0.sortOrder == 0 }) else { return }
for index in groups.indices {
groups[index].sortOrder = index
}
saveGroups(groups)
let count = groups.count
Self.logger.info("Migrated sortOrder for \(count) groups")
}

/// Save all groups
func saveGroups(_ groups: [ConnectionGroup]) {
do {
Expand All @@ -45,14 +59,22 @@ final class GroupStorage {
}
}

/// Add a new group
func addGroup(_ group: ConnectionGroup) {
/// Add a new group (rejects case-insensitive duplicate names among siblings).
/// Returns `true` if the group was added, `false` if a sibling with the same name exists.
@discardableResult
func addGroup(_ group: ConnectionGroup) -> Bool {
var groups = loadGroups()
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
return
let hasDuplicate = groups.contains {
$0.parentGroupId == group.parentGroupId
&& $0.name.caseInsensitiveCompare(group.name) == .orderedSame
}
if hasDuplicate {
Self.logger.debug("Ignoring attempt to add duplicate group name: \(group.name, privacy: .public)")
return false
}
groups.append(group)
saveGroups(groups)
return true
}

/// Update an existing group
Expand All @@ -64,16 +86,101 @@ final class GroupStorage {
}
}

/// Delete a group
/// Delete a group and all its descendants, including their connections.
func deleteGroup(_ group: ConnectionGroup) {
SyncChangeTracker.shared.markDeleted(.group, id: group.id.uuidString)
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 on lines +100 to +115
Copy link

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)")
}
}

// MARK: - Helpers

/// Recursively collect all descendant group IDs
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

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/Connection/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
}
}
3 changes: 3 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var color: ConnectionColor
var tagId: UUID?
var groupId: UUID?
var sortOrder: Int = 0
var safeModeLevel: SafeModeLevel
var aiPolicy: AIConnectionPolicy?
var additionalFields: [String: String] = [:]
Expand Down Expand Up @@ -428,6 +429,7 @@ struct DatabaseConnection: Identifiable, Hashable {
color: ConnectionColor = .none,
tagId: UUID? = nil,
groupId: UUID? = nil,
sortOrder: Int = 0,
safeModeLevel: SafeModeLevel = .silent,
aiPolicy: AIConnectionPolicy? = nil,
mongoAuthSource: String? = nil,
Expand All @@ -451,6 +453,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.color = color
self.tagId = tagId
self.groupId = groupId
self.sortOrder = sortOrder
self.safeModeLevel = safeModeLevel
self.aiPolicy = aiPolicy
self.redisDatabase = redisDatabase
Expand Down
7 changes: 5 additions & 2 deletions TablePro/Views/Connection/ConnectionFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
@State private var sslClientCertPath: String = ""
@State private var sslClientKeyPath: String = ""

// Color and Tag
// Color, Tag, and Group
@State private var connectionColor: ConnectionColor = .none
@State private var selectedTagId: UUID?
@State private var selectedGroupId: UUID?
Expand Down Expand Up @@ -1053,7 +1053,10 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length
connectToDatabase(connectionToSave)
} else {
if let index = savedConnections.firstIndex(where: { $0.id == connectionToSave.id }) {
savedConnections[index] = connectionToSave
// Preserve sortOrder from existing connection
var updated = connectionToSave
updated.sortOrder = savedConnections[index].sortOrder
savedConnections[index] = updated
Comment on lines +1056 to +1059
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserving sortOrder unconditionally breaks cross-group edits.

At Line 1058, updated.sortOrder is always copied from the old record. If the user changed groupId, this reuses an order from a different sibling set and can cause ordering collisions.

🔧 Suggested fix
             if let index = savedConnections.firstIndex(where: { $0.id == connectionToSave.id }) {
-                // Preserve sortOrder from existing connection
                 var updated = connectionToSave
-                updated.sortOrder = savedConnections[index].sortOrder
+                let previous = savedConnections[index]
+                if previous.groupId == updated.groupId {
+                    updated.sortOrder = previous.sortOrder
+                } else {
+                    let nextSortOrder = (
+                        savedConnections
+                            .filter { $0.id != updated.id && $0.groupId == updated.groupId }
+                            .map(\.sortOrder)
+                            .max() ?? -1
+                    ) + 1
+                    updated.sortOrder = nextSortOrder
+                }
                 savedConnections[index] = updated
                 storage.saveConnections(savedConnections)
                 SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString)
             }
📝 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
// Preserve sortOrder from existing connection
var updated = connectionToSave
updated.sortOrder = savedConnections[index].sortOrder
savedConnections[index] = updated
var updated = connectionToSave
let previous = savedConnections[index]
if previous.groupId == updated.groupId {
updated.sortOrder = previous.sortOrder
} else {
let nextSortOrder = (
savedConnections
.filter { $0.id != updated.id && $0.groupId == updated.groupId }
.map(\.sortOrder)
.max() ?? -1
) + 1
updated.sortOrder = nextSortOrder
}
savedConnections[index] = updated
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@TablePro/Views/Connection/ConnectionFormView.swift` around lines 1056 - 1059,
The code unconditionally copies savedConnections[index].sortOrder into
updated.sortOrder which breaks ordering when the user changed
connectionToSave.groupId; instead, check if connectionToSave.groupId ==
savedConnections[index].groupId and only preserve updated.sortOrder in that
case, otherwise assign a new sortOrder appropriate for the target group (e.g.,
compute max sortOrder among savedConnections with the new groupId and set
updated.sortOrder = max + 1 or nil as your model requires) before replacing
savedConnections[index]; update the logic around connectionToSave,
updated.sortOrder, and savedConnections to implement this conditional behavior.

storage.saveConnections(savedConnections)
SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString)
}
Expand Down
Loading
Loading