diff --git a/CHANGELOG.md b/CHANGELOG.md index ee75990e..03089a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - Add database and schema switching for PostgreSQL connections via ⌘K ## [0.14.0] - 2026-03-05 diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index b4001e05..d5a613c7 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -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 { @@ -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() @@ -122,6 +134,7 @@ final class ConnectionStorage { color: connection.color, tagId: connection.tagId, groupId: connection.groupId, + sortOrder: connection.sortOrder, safeModeLevel: connection.safeModeLevel, aiPolicy: connection.aiPolicy, redisDatabase: connection.redisDatabase, @@ -129,9 +142,19 @@ final class ConnectionStorage { 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) @@ -149,7 +172,7 @@ final class ConnectionStorage { saveTOTPSecret(totpSecret, for: newId) } - return duplicate + return placed } // MARK: - Keychain (Password Storage) @@ -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? @@ -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 @@ -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 @@ -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) @@ -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) @@ -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, diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 2ca9fb48..c1345374 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -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() @@ -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 { @@ -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 @@ -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) + } + + /// 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 { + 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) { + 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 { + var result = Set() + 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 + } } diff --git a/TablePro/Models/Connection/ConnectionGroup.swift b/TablePro/Models/Connection/ConnectionGroup.swift index 99164afb..0269db41 100644 --- a/TablePro/Models/Connection/ConnectionGroup.swift +++ b/TablePro/Models/Connection/ConnectionGroup.swift @@ -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 } } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index cb871b9a..f9dfefb6 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -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] = [:] @@ -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, @@ -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 diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 8e6583d1..fe167cb9 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -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? @@ -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 storage.saveConnections(savedConnections) SyncChangeTracker.shared.markDirty(.connection, id: connectionToSave.id.uuidString) } diff --git a/docs/docs.json b/docs/docs.json index 74a901a2..5fb214bc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -26,7 +26,7 @@ "groups": [ { "group": "Getting Started", - "pages": ["index", "quickstart", "installation", "changelog"] + "pages": ["index", "quickstart", "installation", "download", "changelog"] }, { "group": "Database Connections", @@ -121,6 +121,7 @@ "vi/index", "vi/quickstart", "vi/installation", + "vi/download", "vi/changelog" ] }, @@ -350,7 +351,7 @@ "primary": { "type": "button", "label": "Download", - "href": "https://github.com/datlechin/tablepro/releases" + "href": "/download" } }, "contextual": { diff --git a/docs/download.mdx b/docs/download.mdx new file mode 100644 index 00000000..9bded182 --- /dev/null +++ b/docs/download.mdx @@ -0,0 +1,55 @@ +--- +title: Download TablePro +description: Download TablePro for your Mac +--- + +# Download TablePro + +## Install via Homebrew + +The fastest way to install TablePro: + +```bash +brew install datlechin/tap/tablepro +``` + + +Homebrew automatically removes the macOS quarantine attribute, so you won't need to run `xattr -cr` on first launch. + + +## Direct Download + +Download the latest version for your Mac's architecture: + + + + For Macs with M1, M2, M3, M4, or M5 chip. Download `TablePro-arm64.dmg`. + + + For Macs with Intel processor. Download `TablePro-x86_64.dmg`. + + + +### Which version do I need? + +Click the **Apple menu** () > **About This Mac**: + +- **Chip: Apple M1/M2/M3/M4/M5** — download Apple Silicon +- **Processor: Intel** — download Intel + +### After downloading + +1. Open the `.dmg` file and drag TablePro to Applications +2. On first launch, macOS will block the app. Open **Terminal** and run: + +```bash +xattr -cr /Applications/TablePro.app +``` + +3. Open TablePro again — you only need to do this once + +See the [Installation guide](/installation#first-launch-security) for more details. + +## All Releases + +Browse all versions on the [GitHub Releases page](https://github.com/datlechin/tablepro/releases). diff --git a/docs/vi/download.mdx b/docs/vi/download.mdx new file mode 100644 index 00000000..5afb8134 --- /dev/null +++ b/docs/vi/download.mdx @@ -0,0 +1,55 @@ +--- +title: Tải TablePro +description: Tải TablePro cho máy Mac của bạn +--- + +# Tải TablePro + +## Cài đặt qua Homebrew + +Cách nhanh nhất để cài đặt TablePro: + +```bash +brew install datlechin/tap/tablepro +``` + + +Homebrew tự động gỡ bỏ thuộc tính quarantine của macOS, nên bạn không cần chạy `xattr -cr` khi khởi chạy lần đầu. + + +## Tải trực tiếp + +Tải phiên bản mới nhất cho kiến trúc máy Mac của bạn: + + + + Dành cho máy Mac có chip M1, M2, M3, M4 hoặc M5. Tải `TablePro-arm64.dmg`. + + + Dành cho máy Mac có bộ xử lý Intel. Tải `TablePro-x86_64.dmg`. + + + +### Tôi cần phiên bản nào? + +Nhấp vào **menu Apple** () > **Giới thiệu về máy Mac này**: + +- **Chip: Apple M1/M2/M3/M4/M5** — tải Apple Silicon +- **Bộ xử lý: Intel** — tải Intel + +### Sau khi tải + +1. Mở file `.dmg` và kéo TablePro vào thư mục Applications +2. Khi khởi chạy lần đầu, macOS sẽ chặn ứng dụng. Mở **Terminal** và chạy: + +```bash +xattr -cr /Applications/TablePro.app +``` + +3. Mở lại TablePro — bạn chỉ cần làm điều này một lần + +Xem [Hướng dẫn cài đặt](/vi/installation#bảo-mật-khi-khởi-chạy-lần-đầu) để biết thêm chi tiết. + +## Tất cả phiên bản + +Xem tất cả phiên bản trên [trang GitHub Releases](https://github.com/datlechin/tablepro/releases).