Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
- Add database and schema switching for PostgreSQL connections via ⌘K

## [0.14.0] - 2026-03-05
Expand Down
48 changes: 43 additions & 5 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,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 @@ -62,6 +63,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 @@ -115,14 +127,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 All @@ -136,7 +166,7 @@ final class ConnectionStorage {
saveKeyPassphrase(keyPassphrase, for: newId)
}

return duplicate
return placed
}

// MARK: - Keychain (Password Storage)
Expand Down Expand Up @@ -358,6 +388,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 +429,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 +470,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
mssqlSchema = try container.decodeIfPresent(String.self, forKey: .mssqlSchema)
}

Expand Down Expand Up @@ -475,6 +512,7 @@ private struct StoredConnection: Codable {
groupId: parsedGroupId,
isReadOnly: isReadOnly,
aiPolicy: parsedAIPolicy,
sortOrder: sortOrder,
mssqlSchema: mssqlSchema
)
}
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 @@ -44,14 +58,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 @@ -63,15 +85,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)
}

/// 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))
}
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
}
}
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?
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