Skip to content
Open
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
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
}

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

// 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))
}
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 @@ -261,6 +261,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var aiPolicy: AIConnectionPolicy?
var mongoReadPreference: String?
var mongoWriteConcern: String?
var sortOrder: Int
var redisDatabase: Int?

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