diff --git a/.gitignore b/.gitignore index 2c9051166..c3d1be70c 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,7 @@ Thumbs.db *.p12 *.mobileprovision Secrets.xcconfig +Local.xcconfig # Debug *.log @@ -154,3 +155,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..30ada05a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are scoped to the connection, database, and schema, pinned to the top of their section, appear in a dedicated Tables group in the Favorites tab, and sync through iCloud when the Table Favorites toggle is on. +- A plus button in the bottom bar of the Tables sidebar opens a menu to create a new table or view, without right-clicking. It's disabled while safe mode blocks writes. +- Recent section at the top of the Tables sidebar tracks the last 10 tables you opened per connection and database, in-memory for the session. Off by default, turn it on in Settings > General > Sidebar. (#1352) +- A connection can read its password from a file, environment variable, or command at connect time instead of the Keychain, so scripts can provision a connection without entering the password by hand. (#1254) + +### Changed + +- The Maintenance submenu in the sidebar context menu is hidden when no maintenance operations are available or the target is read-only, instead of showing an empty disabled menu. +- The window minimum width now adjusts to the visible panes, so opening the inspector on a small window no longer pushes content off-screen. + +### Removed + +- "Create New Table…" from the sidebar right-click menu. Use the plus button in the Tables sidebar footer instead. + +### Fixed + +- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. + ## [0.46.0] - 2026-05-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 9c9b74a42..4442aa733 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -202,6 +202,13 @@ remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; remoteInfo = TablePro; }; + 5AF00A112FB9000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; + remoteInfo = TablePro; + }; 5ABQR00000000000000000C0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -297,6 +304,7 @@ 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVInspectorPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABQR00200000000000000A1 /* BigQueryAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryAuth.swift; sourceTree = ""; }; 5ABQR00200000000000000A2 /* BigQueryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryConnection.swift; sourceTree = ""; }; 5ABQR00200000000000000A3 /* BigQueryPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryPlugin.swift; sourceTree = ""; }; @@ -677,6 +685,11 @@ path = TableProTests; sourceTree = ""; }; + 5AF00A122FB9000000000001 /* TableProUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TableProUITests; + sourceTree = ""; + }; 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -708,6 +721,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A132FB9000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F52F97DA8100611C1F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -939,6 +959,7 @@ 5A86E000500000000 /* Plugins/MQLExportPlugin */, 5A86F000500000000 /* Plugins/SQLImportPlugin */, 5ABCC5A82F43856700EAF3FC /* TableProTests */, + 5AF00A122FB9000000000001 /* TableProUITests */, 5A32BC012F9D5F1300BAEB5F /* mcp-server */, 5A1091C82EF17EDC0055EA7C /* Products */, 5A05FBC72F3EDF7500819CD7 /* Recovered References */, @@ -968,6 +989,7 @@ 5A86E000100000000 /* MQLExport.tableplugin */, 5A86F000100000000 /* SQLImport.tableplugin */, 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, + 5AF00A102FB9000000000001 /* TableProUITests.xctest */, 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */, 5ADDB00300000000000000A0 /* DynamoDBDriverPlugin.tableplugin */, 5ABQR00300000000000000A0 /* BigQueryDriverPlugin.tableplugin */, @@ -1524,6 +1546,27 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 5AF00A142FB9000000000001 /* TableProUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */; + buildPhases = ( + 5AF00A152FB9000000000001 /* Sources */, + 5AF00A132FB9000000000001 /* Frameworks */, + 5AF00A162FB9000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5AF00A172FB9000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5AF00A122FB9000000000001 /* TableProUITests */, + ); + name = TableProUITests; + productName = TableProUITests; + productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */; @@ -1671,6 +1714,10 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; + 5AF00A142FB9000000000001 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 5A1091C62EF17EDC0055EA7C; + }; 5AE4F4732F6BC0640097AC5B = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -1725,6 +1772,7 @@ 5A86E000000000000 /* MQLExport */, 5A86F000000000000 /* SQLImport */, 5ABCC5A62F43856700EAF3FC /* TableProTests */, + 5AF00A142FB9000000000001 /* TableProUITests */, 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, 5AE4F4732F6BC0640097AC5B /* CloudflareD1DriverPlugin */, 5ADDB00600000000000000B0 /* DynamoDBDriverPlugin */, @@ -1744,6 +1792,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A162FB9000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F62F97DA8100611C1F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1929,6 +1984,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A152FB9000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A3BE6F42F97DA8100611C1F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2212,6 +2274,11 @@ target = 5A1091C62EF17EDC0055EA7C /* TablePro */; targetProxy = 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */; }; + 5AF00A172FB9000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A1091C62EF17EDC0055EA7C /* TablePro */; + targetProxy = 5AF00A112FB9000000000001 /* PBXContainerItemProxy */; + }; 5ABQR00000000000000000C1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */; @@ -3713,6 +3780,48 @@ }; name = Release; }; + 5AF00A182FB9000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Debug; + }; + 5AF00A1A2FB9000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Release; + }; 5ABQR00700000000000000B1 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4116,6 +4225,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AF00A182FB9000000000001 /* Debug */, + 5AF00A1A2FB9000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index f99c67cbd..f5e304a4e 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -41,6 +41,17 @@ ReferencedContainer = "container:TablePro.xcodeproj"> + + + + ? + private let lock = NSLock() + + init(userDefaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { + self.defaults = userDefaults + self.syncTracker = syncTracker + } + + func loadFavorites() -> Set { + lock.lock() + defer { lock.unlock() } + return _loadFavorites() + } + + func favorites(for connectionId: UUID) -> Set { + lock.lock() + defer { lock.unlock() } + return _loadFavorites().filter { $0.connectionId == connectionId } + } + + func isFavorite(name: String, schema: String?, database: String?, connectionId: UUID) -> Bool { + lock.lock() + defer { lock.unlock() } + return _loadFavorites().contains( + FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + ) + } + + func toggle(name: String, schema: String?, database: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + let action: TrackedAction = mutate { favorites in + if favorites.contains(entry) { + favorites.remove(entry) + return .removed(entry) + } + favorites.insert(entry) + return .added(entry) + } + notify(after: action) + } + + @discardableResult + func addFavorite(name: String, schema: String?, database: String?, connectionId: UUID) -> Bool { + let entry = FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + let action: TrackedAction = mutate { favorites in + guard favorites.insert(entry).inserted else { return .noChange } + return .added(entry) + } + notify(after: action) + return action.changed + } + + @discardableResult + func addFavoriteWithoutSync(_ entry: FavoriteEntry) -> Bool { + let action = mutate { favorites in + favorites.insert(entry).inserted ? .added(entry) : .noChange + } + notify(after: action, skipSync: true) + return action.changed + } + + func removeFavorite(name: String, schema: String?, database: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + let action = mutate { favorites in + favorites.remove(entry) != nil ? .removed(entry) : .noChange + } + notify(after: action) + } + + func removeFavoriteWithoutSync(_ entry: FavoriteEntry) { + let action = mutate { favorites in + favorites.remove(entry) != nil ? .removed(entry) : .noChange + } + notify(after: action, skipSync: true) + } + + func removeFavoriteWithoutSync(id: String) { + let action = mutate { favorites in + guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { return .noChange } + favorites.remove(entry) + return .removed(entry) + } + notify(after: action, skipSync: true) + } + + @discardableResult + func removeFavorites(for connectionId: UUID) -> [FavoriteEntry] { + var removed: [FavoriteEntry] = [] + lock.lock() + var favorites = _loadFavorites() + let toRemove = favorites.filter { $0.connectionId == connectionId } + if !toRemove.isEmpty { + favorites.subtract(toRemove) + _persist(favorites) + removed = Array(toRemove) + } + lock.unlock() + + guard !removed.isEmpty else { return [] } + for entry in removed { + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + } + NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) + return removed + } + + static func syncId(for entry: FavoriteEntry) -> String { + let raw = entry.connectionId.uuidString + + "|" + (entry.database ?? "") + + "|" + (entry.schema ?? "") + + "|" + entry.name + return raw.sha256 + } + + private enum TrackedAction { + case noChange + case added(FavoriteEntry) + case removed(FavoriteEntry) + + var changed: Bool { + if case .noChange = self { return false } + return true + } + } + + private func mutate(_ block: (inout Set) -> TrackedAction) -> TrackedAction { + lock.lock() + defer { lock.unlock() } + var favorites = _loadFavorites() + let action = block(&favorites) + guard action.changed else { return action } + _persist(favorites) + return action + } + + private func notify(after action: TrackedAction, skipSync: Bool = false) { + switch action { + case .noChange: + return + case .added(let entry): + if !skipSync { + syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: entry)) + } + NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) + case .removed(let entry): + if !skipSync { + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + } + NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) + } + } + + private func _loadFavorites() -> Set { + if let cache { return cache } + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(Set.self, from: data) else { + cache = [] + return [] + } + cache = decoded + return decoded + } + + private func _persist(_ favorites: Set) { + cache = favorites + guard let data = try? JSONEncoder().encode(favorites) else { + Self.logger.error("Failed to encode favorite tables") + return + } + defaults.set(data, forKey: key) + } +} diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index a67d4b293..9e04945c4 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -104,15 +104,15 @@ final class GroupStorage { let storage = connectionStorageProvider() var connections = storage.loadConnections() - var changed = false + var changed: [DatabaseConnection] = [] for i in connections.indices { if let gid = connections[i].groupId, allIdsToDelete.contains(gid) { connections[i].groupId = nil - changed = true + changed.append(connections[i]) } } - if changed { - if !storage.saveConnections(connections) { + if !changed.isEmpty { + if !storage.updateConnections(changed) { Self.logger.error("Failed to clear groupId references after group deletion") } } diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..1c1653c4a --- /dev/null +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -0,0 +1,68 @@ +import Foundation + +extension Notification.Name { + static let recentTablesDidChange = Notification.Name("RecentTablesDidChange") +} + +@MainActor +final class RecentTablesStore { + static let shared = RecentTablesStore() + + struct Key: Hashable { + let connectionID: UUID + let database: String? + } + + struct Entry: Hashable, Identifiable { + let name: String + let schema: String? + let type: TableInfo.TableType + + var id: String { schema.map { "\($0).\(name)" } ?? name } + } + + private var entriesByKey: [Key: [Entry]] = [:] + private let cap = 10 + + init() {} + + func push(connectionID: UUID, database: String?, table: TableInfo) { + let key = Key(connectionID: connectionID, database: database) + var list = entriesByKey[key] ?? [] + let newEntryId = entryId(name: table.name, schema: table.schema) + list.removeAll { $0.id == newEntryId } + list.insert( + Entry( + name: table.name, + schema: table.schema, + type: table.type + ), + at: 0 + ) + if list.count > cap { + list = Array(list.prefix(cap)) + } + entriesByKey[key] = list + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + func entries(connectionID: UUID, database: String?) -> [Entry] { + entriesByKey[Key(connectionID: connectionID, database: database)] ?? [] + } + + func clear(connectionID: UUID, database: String?) { + entriesByKey.removeValue(forKey: Key(connectionID: connectionID, database: database)) + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + func clearAll() { + entriesByKey.removeAll() + NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) + } + + var cappedSize: Int { cap } + + private func entryId(name: String, schema: String?) -> String { + schema.map { "\($0).\(name)" } ?? name + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fa0fd3577..20c538b9a 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -167,12 +167,25 @@ final class SyncCoordinator { changeTracker.markDirty(.sshProfile, id: profile.id.uuidString) } + let favoriteTables = services.favoriteTablesStorage.loadFavorites() + for entry in favoriteTables { + changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: entry)) + } + // Mark all settings categories as dirty for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] { changeTracker.markDirty(.settings, id: category) } - Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, \(sshProfiles.count) SSH profiles, 8 settings categories") + let summary = [ + "connections=\(connections.count)", + "groups=\(groups.count)", + "tags=\(tags.count)", + "sshProfiles=\(sshProfiles.count)", + "favoriteTables=\(favoriteTables.count)", + "settings=8" + ].joined(separator: ", ") + Self.logger.info("Marked all local data dirty: \(summary, privacy: .public)") } /// Called when user disables sync in settings @@ -291,6 +304,10 @@ final class SyncCoordinator { } } + if settings.syncTableFavorites { + collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + // Deduplicate deletion IDs to prevent CloudKit "can't delete same record twice" error let uniqueDeletions = Array(Set(recordIDsToDelete)) @@ -312,6 +329,9 @@ final class SyncCoordinator { if settings.syncSettings { changeTracker.clearAllDirty(.settings) } + if settings.syncTableFavorites { + changeTracker.clearAllDirty(.tableFavorite) + } // Clear tombstones only for types that were actually pushed if settings.syncConnections { @@ -337,6 +357,11 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) } } + if settings.syncTableFavorites { + for tombstone in metadataStorage.tombstones(for: .tableFavorite) { + metadataStorage.removeTombstone(type: .tableFavorite, id: tombstone.id) + } + } Self.logger.info("Push completed: \(recordsToSave.count) saved, \(recordIDsToDelete.count) deleted") } catch let error as CKError where error.code == .serverRecordChanged { @@ -403,6 +428,7 @@ final class SyncCoordinator { let groupTombstoneIds = Set(metadataStorage.tombstones(for: .group).map(\.id)) let tagTombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) let sshTombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) + let tableFavoriteTombstoneIds = Set(metadataStorage.tombstones(for: .tableFavorite).map(\.id)) for record in result.changedRecords { switch record.recordType { @@ -422,6 +448,8 @@ final class SyncCoordinator { applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) + case SyncRecordType.tableFavorite.rawValue where settings.syncTableFavorites: + applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) default: break } @@ -431,6 +459,7 @@ final class SyncCoordinator { var groupIdsToDelete: Set = [] var tagIdsToDelete: Set = [] var sshProfileIdsToDelete: Set = [] + var tableFavoriteIdsToDelete: Set = [] for recordID in result.deletedRecordIDs { let name = recordID.recordName @@ -449,6 +478,8 @@ final class SyncCoordinator { } else if name.hasPrefix("SSHProfile_"), let uuid = UUID(uuidString: String(name.dropFirst("SSHProfile_".count))) { sshProfileIdsToDelete.insert(uuid) + } else if name.hasPrefix("FavoriteTable_") { + tableFavoriteIdsToDelete.insert(String(name.dropFirst("FavoriteTable_".count))) } } @@ -474,6 +505,9 @@ final class SyncCoordinator { profiles.removeAll { sshProfileIdsToDelete.contains($0.id) } services.sshProfileStorage.saveProfilesWithoutSync(profiles) } + for id in tableFavoriteIdsToDelete { + services.favoriteTablesStorage.removeFavoriteWithoutSync(id: id) + } if actualConnectionChanges || groupsOrTagsChanged { services.appEvents.connectionUpdated.send(nil) @@ -517,6 +551,7 @@ final class SyncCoordinator { } var merged = remoteConnection merged.localOnly = connections[index].localOnly + merged.passwordSource = connections[index].passwordSource connections[index] = merged } else { connections.append(remoteConnection) @@ -584,10 +619,31 @@ final class SyncCoordinator { do { try applySettingsData(data, for: category) } catch { - Self.logger.error("Skipping remote settings \(record.recordID.recordName, privacy: .public) (\(category, privacy: .public)): \(error.localizedDescription, privacy: .public)") + let recordName = record.recordID.recordName + let message = error.localizedDescription + Self.logger.error( + "Skipping remote settings \(recordName, privacy: .public) (\(category, privacy: .public)): \(message, privacy: .public)" + ) } } + @discardableResult + private func applyRemoteTableFavorite(_ record: CKRecord, tombstoneIds: Set) -> Bool { + let entry: FavoriteTablesStorage.FavoriteEntry + do { + entry = try SyncRecordMapper.favoriteEntry(from: record) + } catch { + let recordName = record.recordID.recordName + let message = error.localizedDescription + Self.logger.error( + "Skipping remote favorite table \(recordName, privacy: .public): \(message, privacy: .public)" + ) + return false + } + if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: entry)) { return false } + return services.favoriteTablesStorage.addFavoriteWithoutSync(entry) + } + // MARK: - Observers private func observeAccountChanges() { @@ -688,6 +744,7 @@ final class SyncCoordinator { case SyncRecordType.tag.rawValue: syncRecordType = .tag case SyncRecordType.settings.rawValue: syncRecordType = .settings case SyncRecordType.sshProfile.rawValue: syncRecordType = .sshProfile + case SyncRecordType.tableFavorite.rawValue: syncRecordType = .tableFavorite default: continue } @@ -826,4 +883,24 @@ final class SyncCoordinator { ) } } + + private func collectDirtyTableFavorites( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyIds = changeTracker.dirtyRecords(for: .tableFavorite) + if !dirtyIds.isEmpty { + let favorites = services.favoriteTablesStorage.loadFavorites() + for entry in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: entry)) { + records.append(SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID)) + } + } + + for tombstone in metadataStorage.tombstones(for: .tableFavorite) { + deletions.append( + SyncRecordMapper.recordID(type: .tableFavorite, id: tombstone.id, in: zoneID) + ) + } + } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 6eba88390..8fb182262 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -18,6 +18,7 @@ enum SyncRecordType: String, CaseIterable { case settings = "AppSettings" case favorite = "SQLFavorite" case favoriteFolder = "SQLFavoriteFolder" + case tableFavorite = "FavoriteTable" case sshProfile = "SSHProfile" } @@ -55,6 +56,7 @@ struct SyncRecordMapper { case .settings: recordName = "Settings_\(id)" case .favorite: recordName = "Favorite_\(id)" case .favoriteFolder: recordName = "FavoriteFolder_\(id)" + case .tableFavorite: recordName = "FavoriteTable_\(id)" case .sshProfile: recordName = "SSHProfile_\(id)" } return CKRecord.ID(recordName: recordName, zoneID: zone) @@ -113,6 +115,8 @@ struct SyncRecordMapper { // the sync schema in the future, apply path contraction to its snapshot. // cloudflareTunnelMode is also NOT synced: it is device-local runtime // config and its service-token secrets live in the Keychain. + // passwordSource is also NOT synced: its file path, env var, or command + // is device-local and may not exist or resolve on another Mac. do { let sshData = try encoder.encode(Self.makePortable(connection.sshConfig)) record["sshConfigJson"] = sshData as CKRecordValue @@ -326,6 +330,45 @@ struct SyncRecordMapper { record["settingsJson"] as? Data } + // MARK: - Table Favorite + + static func toCKRecord(favoriteEntry entry: FavoriteTablesStorage.FavoriteEntry, in zone: CKRecordZone.ID) -> CKRecord { + let favoriteId = FavoriteTablesStorage.syncId(for: entry) + let recordID = recordID(type: .tableFavorite, id: favoriteId, in: zone) + let record = CKRecord(recordType: SyncRecordType.tableFavorite.rawValue, recordID: recordID) + + record["connectionId"] = entry.connectionId.uuidString as CKRecordValue + record["name"] = entry.name as CKRecordValue + if let database = entry.database { + record["database"] = database as CKRecordValue + } + if let schema = entry.schema { + record["schema"] = schema as CKRecordValue + } + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func favoriteEntry(from record: CKRecord) throws -> FavoriteTablesStorage.FavoriteEntry { + guard let name = record["name"] as? String, !name.isEmpty else { + throw SyncDecodeError.missingRequiredField("name") + } + guard let connectionIdString = record["connectionId"] as? String, + let connectionId = UUID(uuidString: connectionIdString) else { + throw SyncDecodeError.missingRequiredField("connectionId") + } + let database = record["database"] as? String + let schema = record["schema"] as? String + return FavoriteTablesStorage.FavoriteEntry( + connectionId: connectionId, + database: database, + schema: schema, + name: name + ) + } + // MARK: - SSH Profile static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { diff --git a/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift new file mode 100644 index 000000000..45cb71a7c --- /dev/null +++ b/TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift @@ -0,0 +1,237 @@ +// +// PasswordSourceResolver.swift +// TablePro +// + +import Foundation +import os + +/// Resolves a connection password from an external source declared in connections.json. +/// File and command sources require a non-sandboxed build; TablePro ships with the hardened +/// runtime and no App Sandbox, so spawning a process and reading arbitrary files is allowed. +enum PasswordSourceResolver { + private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSourceResolver") + + private static let commandTimeoutSeconds: UInt64 = 30 + private static let maxOutputBytes = 1_048_576 + + enum ResolutionError: LocalizedError { + case fileNotFound(path: String) + case fileUnreadable(path: String) + case environmentVariableNotSet(name: String) + case commandFailed(exitCode: Int32, stderr: String) + case commandTimedOut + case outputTooLarge + case emptyPassword + + var errorDescription: String? { + switch self { + case let .fileNotFound(path): + return String(format: String(localized: "Password file not found: %@"), path) + case let .fileUnreadable(path): + return String(format: String(localized: "Could not read password file: %@"), path) + case let .environmentVariableNotSet(name): + return String( + format: String(localized: """ + Environment variable %@ is not set in TablePro's environment. \ + Apps launched from the Dock do not inherit shell exports. Launch TablePro \ + from a terminal, or set the variable with launchctl setenv. + """), + name + ) + case let .commandFailed(exitCode, stderr): + let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if message.isEmpty { + return String(format: String(localized: "Password command failed with exit code %d"), exitCode) + } + return String(format: String(localized: "Password command failed (exit %d): %@"), exitCode, message) + case .commandTimedOut: + return String(localized: "Password command timed out after 30 seconds") + case .outputTooLarge: + return String(localized: "Password command produced too much output") + case .emptyPassword: + return String(localized: "The password source produced an empty password") + } + } + } + + static func resolve(_ source: PasswordSource) async throws -> String { + switch source { + case let .file(path): + return try resolveFile(path: path) + case let .env(variable): + return try resolveEnvironment(variable: variable) + case let .command(shell): + return try await resolveCommand(shell: shell, timeoutSeconds: commandTimeoutSeconds) + } + } + + private static func resolveFile(path: String) throws -> String { + let expandedPath = (path as NSString).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expandedPath) else { + throw ResolutionError.fileNotFound(path: expandedPath) + } + warnIfPermissionsInsecure(path: expandedPath) + guard let contents = try? String(contentsOfFile: expandedPath, encoding: .utf8) else { + throw ResolutionError.fileUnreadable(path: expandedPath) + } + return try nonEmpty(contents.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func resolveEnvironment(variable: String) throws -> String { + guard let value = ProcessInfo.processInfo.environment[variable] else { + throw ResolutionError.environmentVariableNotSet(name: variable) + } + return try nonEmpty(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + static func resolveCommand(shell: String, timeoutSeconds: UInt64) async throws -> String { + let output = try await Task.detached(priority: .userInitiated) { () throws -> String in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", shell] + process.environment = augmentedEnvironment() + process.standardInput = FileHandle.nullDevice + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdoutCollector = PipeDataCollector(maxBytes: maxOutputBytes) + let stderrCollector = PipeDataCollector(maxBytes: maxOutputBytes) + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + stdoutCollector.append(chunk) + if stdoutCollector.overflowed, process.isRunning { + process.terminate() + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { handle in + let chunk = handle.availableData + if !chunk.isEmpty { stderrCollector.append(chunk) } + } + + try process.run() + + let didTimeout = AtomicFlag() + let timeoutTask = Task.detached { + try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + if process.isRunning { + didTimeout.set() + process.terminate() + } + } + + process.waitUntilExit() + timeoutTask.cancel() + + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + + if stdoutCollector.overflowed { + throw ResolutionError.outputTooLarge + } + if didTimeout.isSet { + throw ResolutionError.commandTimedOut + } + if process.terminationStatus != 0 { + throw ResolutionError.commandFailed( + exitCode: process.terminationStatus, + stderr: stderrCollector.string + ) + } + return stdoutCollector.string + }.value + + guard !output.contains("\0") else { + throw ResolutionError.emptyPassword + } + return try nonEmpty(output.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private static func augmentedEnvironment() -> [String: String] { + var environment = ProcessInfo.processInfo.environment + let toolPaths = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + var pathComponents = (environment["PATH"] ?? "").split(separator: ":").map(String.init) + for toolPath in toolPaths where !pathComponents.contains(toolPath) { + pathComponents.append(toolPath) + } + environment["PATH"] = pathComponents.joined(separator: ":") + return environment + } + + private static func warnIfPermissionsInsecure(path: String) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let permissions = attributes[.posixPermissions] as? Int else { + return + } + if permissions & 0o077 != 0 { + logger.warning("Password file is group or world accessible; restrict it with chmod 600") + } + } + + private static func nonEmpty(_ password: String) throws -> String { + guard !password.isEmpty else { + throw ResolutionError.emptyPassword + } + return password + } +} + +private final class PipeDataCollector: @unchecked Sendable { + private let lock = NSLock() + private let maxBytes: Int + private var data = Data() + private var didOverflow = false + + init(maxBytes: Int) { + self.maxBytes = maxBytes + } + + func append(_ chunk: Data) { + lock.lock() + defer { lock.unlock() } + let remaining = maxBytes - data.count + guard remaining > 0 else { + didOverflow = true + return + } + if chunk.count > remaining { + data.append(chunk.prefix(remaining)) + didOverflow = true + } else { + data.append(chunk) + } + } + + var overflowed: Bool { + lock.lock() + defer { lock.unlock() } + return didOverflow + } + + var string: String { + lock.lock() + defer { lock.unlock() } + return String(data: data, encoding: .utf8) ?? "" + } +} + +private final class AtomicFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + lock.lock() + value = true + lock.unlock() + } + + var isSet: Bool { + lock.lock() + defer { lock.unlock() } + return value + } +} diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index dda3adc46..ccf097497 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -334,6 +334,7 @@ struct DatabaseConnection: Identifiable, Hashable { var localOnly: Bool = false var isSample: Bool = false var isFavorite: Bool = false + var passwordSource: PasswordSource? var mongoAuthSource: String? { get { additionalFields["mongoAuthSource"]?.nilIfEmpty } @@ -430,6 +431,7 @@ struct DatabaseConnection: Identifiable, Hashable { localOnly: Bool = false, isSample: Bool = false, isFavorite: Bool = false, + passwordSource: PasswordSource? = nil, additionalFields: [String: String]? = nil ) { self.id = id @@ -472,6 +474,7 @@ struct DatabaseConnection: Identifiable, Hashable { self.localOnly = localOnly self.isSample = isSample self.isFavorite = isFavorite + self.passwordSource = passwordSource if let additionalFields { self.additionalFields = additionalFields } else { @@ -520,6 +523,7 @@ extension DatabaseConnection: Codable { case sshConfig, sslConfig, color, tagId, groupId, sshProfileId case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite + case passwordSource } init(from decoder: Decoder) throws { @@ -549,6 +553,7 @@ extension DatabaseConnection: Codable { localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled // Migrate from legacy fields if sshTunnelMode is not present @@ -600,6 +605,7 @@ extension DatabaseConnection: Codable { try container.encode(localOnly, forKey: .localOnly) try container.encode(isSample, forKey: .isSample) try container.encode(isFavorite, forKey: .isFavorite) + try container.encodeIfPresent(passwordSource, forKey: .passwordSource) } } diff --git a/TablePro/Models/Connection/PasswordSource.swift b/TablePro/Models/Connection/PasswordSource.swift new file mode 100644 index 000000000..f86da5acc --- /dev/null +++ b/TablePro/Models/Connection/PasswordSource.swift @@ -0,0 +1,73 @@ +// +// PasswordSource.swift +// TablePro +// + +import Foundation +import os + +/// Declares where a connection's password comes from when it is not stored in the Keychain. +/// Resolved at connect time from a file, an environment variable, or the stdout of a shell command. +enum PasswordSource: Codable, Hashable, Sendable { + case file(path: String) + case env(variable: String) + case command(shell: String) + + private static let logger = Logger(subsystem: "com.TablePro", category: "PasswordSource") + + private enum CodingKeys: String, CodingKey { + case kind, path, variable, shell + } + + private enum Kind: String { + case file, env, command + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case Kind.file.rawValue: + self = .file(path: try container.decode(String.self, forKey: .path)) + case Kind.env.rawValue: + self = .env(variable: try container.decode(String.self, forKey: .variable)) + case Kind.command.rawValue: + self = .command(shell: try container.decode(String.self, forKey: .shell)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, + in: container, + debugDescription: "Unknown passwordSource kind: \(kind)" + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .file(path): + try container.encode(Kind.file.rawValue, forKey: .kind) + try container.encode(path, forKey: .path) + case let .env(variable): + try container.encode(Kind.env.rawValue, forKey: .kind) + try container.encode(variable, forKey: .variable) + case let .command(shell): + try container.encode(Kind.command.rawValue, forKey: .kind) + try container.encode(shell, forKey: .shell) + } + } + + /// Decodes a password source from a connection container, treating a present-but-malformed + /// entry as absent so one bad connection cannot fail loading of the whole store. + static func resilientlyDecoded( + from container: KeyedDecodingContainer, + forKey key: Key + ) -> PasswordSource? { + do { + return try container.decodeIfPresent(PasswordSource.self, forKey: key) + } catch { + logger.warning("Ignoring malformed passwordSource in a connection") + return nil + } + } +} diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index ffa240795..fdbc7475c 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -61,12 +61,16 @@ struct GeneralSettings: Codable, Equatable { /// Whether to share anonymous usage analytics var shareAnalytics: Bool + /// Whether the sidebar shows a Recent section with recently opened tables + var showRecentTables: Bool + static let `default` = GeneralSettings( startupBehavior: .showWelcome, language: .system, automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, - shareAnalytics: true + shareAnalytics: true, + showRecentTables: false ) init( @@ -74,13 +78,15 @@ struct GeneralSettings: Codable, Equatable { language: AppLanguage = .system, automaticallyCheckForUpdates: Bool = true, queryTimeoutSeconds: Int = 60, - shareAnalytics: Bool = true + shareAnalytics: Bool = true, + showRecentTables: Bool = false ) { self.startupBehavior = startupBehavior self.language = language self.automaticallyCheckForUpdates = automaticallyCheckForUpdates self.queryTimeoutSeconds = queryTimeoutSeconds self.shareAnalytics = shareAnalytics + self.showRecentTables = showRecentTables } init(from decoder: Decoder) throws { @@ -90,5 +96,6 @@ struct GeneralSettings: Codable, Equatable { automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 60 shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true + showRecentTables = try container.decodeIfPresent(Bool.self, forKey: .showRecentTables) ?? false } } diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index 41e9228ca..f90b0841c 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -15,6 +15,7 @@ struct SyncSettings: Codable, Equatable { var syncSettings: Bool var syncPasswords: Bool var syncSSHProfiles: Bool + var syncTableFavorites: Bool init( enabled: Bool, @@ -22,7 +23,8 @@ struct SyncSettings: Codable, Equatable { syncGroupsAndTags: Bool, syncSettings: Bool, syncPasswords: Bool = false, - syncSSHProfiles: Bool = true + syncSSHProfiles: Bool = true, + syncTableFavorites: Bool = true ) { self.enabled = enabled self.syncConnections = syncConnections @@ -30,6 +32,7 @@ struct SyncSettings: Codable, Equatable { self.syncSettings = syncSettings self.syncPasswords = syncPasswords self.syncSSHProfiles = syncSSHProfiles + self.syncTableFavorites = syncTableFavorites } init(from decoder: Decoder) throws { @@ -40,6 +43,7 @@ struct SyncSettings: Codable, Equatable { syncSettings = try container.decode(Bool.self, forKey: .syncSettings) syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true + syncTableFavorites = try container.decodeIfPresent(Bool.self, forKey: .syncTableFavorites) ?? true } static let `default` = SyncSettings( @@ -48,6 +52,7 @@ struct SyncSettings: Codable, Equatable { syncGroupsAndTags: true, syncSettings: true, syncPasswords: false, - syncSSHProfiles: true + syncSSHProfiles: true, + syncTableFavorites: true ) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 6158570e6..f0e1ee1f7 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1949,7 +1949,6 @@ } }, "%lld of %lld" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4748,7 +4747,8 @@ } }, "Add to Favorites" : { - + "comment" : "A label that describes an action to add an item to a user's favorites.", + "isCommentAutoGenerated" : true }, "Add validation rules to ensure data integrity" : { "localizations" : { @@ -13367,7 +13367,12 @@ } } }, + "Create New Table" : { + "comment" : "Tooltip and accessibility label for the button that allows the user to create a new table.", + "isCommentAutoGenerated" : true + }, "Create New Table..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21380,6 +21385,10 @@ } } }, + "favorite" : { + "comment" : "A label indicating that a table is marked as a favorite.", + "isCommentAutoGenerated" : true + }, "Favorited" : { }, @@ -22462,7 +22471,6 @@ } }, "Format JSON" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27303,7 +27311,6 @@ } }, "Limit" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30221,7 +30228,6 @@ } }, "Next Page (⌘])" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32657,7 +32663,6 @@ } }, "Offset" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33335,6 +33340,10 @@ } } }, + "Open Table" : { + "comment" : "A context menu option to open a table in the main view.", + "isCommentAutoGenerated" : true + }, "Open Table Tab" : { }, @@ -33904,7 +33913,6 @@ } }, "Pagination Settings" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35935,7 +35943,6 @@ } }, "Previous Page (⌘[)" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36606,6 +36613,10 @@ } } }, + "Queries" : { + "comment" : "A section header for the list of queries in the favorites tab.", + "isCommentAutoGenerated" : true + }, "Query" : { "localizations" : { "tr" : { @@ -38485,7 +38496,8 @@ }, "Remove from Favorites" : { - + "comment" : "A button label that deletes a table from the user's favorites.", + "isCommentAutoGenerated" : true }, "Remove from Group" : { "localizations" : { @@ -46341,6 +46353,7 @@ } }, "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46362,6 +46375,10 @@ } } }, + "Syncs connections, table favorites, settings, and SSH profiles across your Macs via iCloud." : { + "comment" : "A description of the functionality of the \"iCloud Sync\" toggle.", + "isCommentAutoGenerated" : true + }, "Syncs passwords via iCloud Keychain (end-to-end encrypted)." : { "localizations" : { "en" : { diff --git a/TablePro/ViewModels/ConnectionSidebarState.swift b/TablePro/ViewModels/ConnectionSidebarState.swift index 342635ca5..37dc3cd59 100644 --- a/TablePro/ViewModels/ConnectionSidebarState.swift +++ b/TablePro/ViewModels/ConnectionSidebarState.swift @@ -20,9 +20,9 @@ internal final class ConnectionSidebarState { let connectionId: UUID - var selectedFavoriteNodeId: String? { + var selectedFavorite: FavoriteSelection? { didSet { - guard oldValue != selectedFavoriteNodeId else { return } + guard oldValue != selectedFavorite else { return } persistFavoriteSelection() } } @@ -33,14 +33,14 @@ internal final class ConnectionSidebarState { private init(connectionId: UUID) { self.connectionId = connectionId - self.selectedFavoriteNodeId = UserDefaults.standard.string( + self.selectedFavorite = UserDefaults.standard.string( forKey: "sidebar.selectedFavoriteNodeId.\(connectionId.uuidString)" - ) + ).flatMap(FavoriteSelection.init(rawValue:)) } private func persistFavoriteSelection() { - if let selectedFavoriteNodeId { - UserDefaults.standard.set(selectedFavoriteNodeId, forKey: favoriteSelectionKey) + if let rawValue = selectedFavorite?.rawValue { + UserDefaults.standard.set(rawValue, forKey: favoriteSelectionKey) } else { UserDefaults.standard.removeObject(forKey: favoriteSelectionKey) } diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index c444df5c6..cb2e9ddc7 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -13,6 +13,40 @@ internal struct FavoriteEditItem: Identifiable { let folderId: UUID? } +internal enum FavoriteSelection: Hashable { + case table(database: String?, schema: String?, name: String) + case node(id: String) +} + +extension FavoriteSelection: RawRepresentable { + private static let separator = "\u{1}" + + init?(rawValue: String) { + let parts = rawValue.components(separatedBy: Self.separator) + switch parts.first { + case "table" where parts.count == 4: + self = .table( + database: parts[1].isEmpty ? nil : parts[1], + schema: parts[2].isEmpty ? nil : parts[2], + name: parts[3] + ) + case "node" where parts.count >= 2: + self = .node(id: parts.dropFirst().joined(separator: Self.separator)) + default: + return nil + } + } + + var rawValue: String { + switch self { + case .table(let database, let schema, let name): + return ["table", database ?? "", schema ?? "", name].joined(separator: Self.separator) + case .node(let id): + return ["node", id].joined(separator: Self.separator) + } + } +} + internal struct FavoriteNode: Identifiable, Hashable { enum Content: Hashable { case folder(SQLFavoriteFolder) @@ -353,20 +387,8 @@ internal final class FavoritesSidebarViewModel { } } - func favoriteForNodeId(_ id: String) -> SQLFavorite? { - findNode(nodes, id: id, extract: \.asFavorite) - } - - func linkedFavoriteForNodeId(_ id: String) -> LinkedSQLFavorite? { - findNode(nodes, id: id, extract: \.asLinkedFavorite) - } - - func folderForNodeId(_ id: String) -> SQLFavoriteFolder? { - findNode(nodes, id: id, extract: \.asFolder) - } - - func linkedFolderForNodeId(_ id: String) -> LinkedSQLFolder? { - findNode(nodes, id: id, extract: \.asLinkedFolder) + func node(forId id: String) -> FavoriteNode? { + findNode(nodes, id: id, extract: { $0 }) } private func findNode( diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 7719ec365..21f2f35c3 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -49,6 +49,7 @@ final class SidebarViewModel { ) } } + var isRecentsExpanded: Bool = true var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 73bb2df10..e4ef44afc 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -415,10 +415,12 @@ final class WelcomeViewModel { func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) { let ids = Set(targets.map(\.id)) + var updated: [DatabaseConnection] = [] for i in connections.indices where ids.contains(connections[i].id) { connections[i].groupId = groupId + updated.append(connections[i]) } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return @@ -428,10 +430,12 @@ final class WelcomeViewModel { func removeFromGroup(_ targets: [DatabaseConnection]) { let ids = Set(targets.map(\.id)) + var updated: [DatabaseConnection] = [] for i in connections.indices where ids.contains(connections[i].id) { connections[i].groupId = nil + updated.append(connections[i]) } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return @@ -549,26 +553,23 @@ final class WelcomeViewModel { let updatedValidGroupIds = Set(groups.map(\.id)) var order = 0 - var dirtyIds: [String] = [] + var updated: [DatabaseConnection] = [] for i in connections.indices { let isUngrouped = connections[i].groupId.map { !updatedValidGroupIds.contains($0) } ?? true if isUngrouped { if connections[i].sortOrder != order { connections[i].sortOrder = order - dirtyIds.append(connections[i].id.uuidString) + updated.append(connections[i]) } order += 1 } } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return } - if !dirtyIds.isEmpty { - services.syncTracker.markDirty(.connection, ids: dirtyIds) - } rebuildTree() } @@ -591,23 +592,20 @@ final class WelcomeViewModel { connections.move(fromOffsets: globalSource, toOffset: globalDestination) var order = 0 - var dirtyIds: [String] = [] + var updated: [DatabaseConnection] = [] for i in connections.indices where connections[i].groupId == group.id { if connections[i].sortOrder != order { connections[i].sortOrder = order - dirtyIds.append(connections[i].id.uuidString) + updated.append(connections[i]) } order += 1 } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return } - if !dirtyIds.isEmpty { - services.syncTracker.markDirty(.connection, ids: dirtyIds) - } rebuildTree() } diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 1388267ed..e30284890 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -130,7 +130,7 @@ struct ConflictResolutionView: View { if let color = record["color"] as? String { fieldRow(label: "Color", value: color) } - case .favorite, .favoriteFolder: + case .favorite, .favoriteFolder, .tableFavorite: if let name = record["name"] as? String { fieldRow(label: String(localized: "Name"), value: name) } diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 95c45755d..568f64c5e 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -288,6 +288,7 @@ final class ConnectionFormCoordinator { startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, localOnly: advanced.localOnly, + passwordSource: originalConnection?.passwordSource, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields ) @@ -459,6 +460,7 @@ final class ConnectionFormCoordinator { redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, + passwordSource: auth.password.isEmpty ? originalConnection?.passwordSource : nil, additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields ) temporaryTestIds.insert(testConn.id) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 813158eff..3ca514e38 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,6 +21,13 @@ extension MainContentCoordinator { redirectToSibling: Bool = false, forceNonPreview: Bool = false ) { + if AppSettingsManager.shared.general.showRecentTables { + RecentTablesStore.shared.push( + connectionID: connection.id, + database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, + table: table + ) + } openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 81c88bd00..4bd1553b7 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -54,6 +54,11 @@ struct GeneralSettingsView: View { .help("When enabled, tabs from different connections share the same window instead of opening separate windows.") } + Section("Sidebar") { + Toggle("Show recent tables", isOn: $settings.showRecentTables) + .help("Adds a Recent section at the top of the Tables sidebar with the last tables you opened per connection and database.") + } + Section("Query Execution") { Picker("Query timeout:", selection: $settings.queryTimeoutSeconds) { Text("No limit").tag(0) diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index ea9032aed..79201d7e0 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -24,7 +24,7 @@ struct SyncSection: View { syncCoordinator.disableSync() } } - .help("Syncs connections, settings, and SSH profiles across your Macs via iCloud.") + .help("Syncs connections, table favorites, settings, and SSH profiles across your Macs via iCloud.") .disabled(!isProAvailable) } header: { HStack(spacing: 6) { @@ -120,6 +120,7 @@ struct SyncSection: View { Toggle("Groups & Tags:", isOn: $settingsManager.sync.syncGroupsAndTags) Toggle("SSH Profiles:", isOn: $settingsManager.sync.syncSSHProfiles) Toggle("Settings:", isOn: $settingsManager.sync.syncSettings) + Toggle("Table Favorites:", isOn: $settingsManager.sync.syncTableFavorites) } } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 30e9fc5ba..f878742b3 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -1,12 +1,8 @@ -// -// FavoritesTabView.swift -// TablePro -// - import SwiftUI internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel + @State private var favoriteTables: [FavoriteTablesStorage.FavoriteEntry] = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @State private var linkedFileToTrash: LinkedSQLFavorite? @@ -17,14 +13,36 @@ internal struct FavoritesTabView: View { @FocusState private var isRenameFocused: Bool let connectionId: UUID let windowState: WindowSidebarState + let tables: [TableInfo] @Bindable private var sidebarState: ConnectionSidebarState private var coordinator: MainContentCoordinator? private var searchText: String { windowState.favoritesSearchText } + private var activeDatabase: String? { + let name = coordinator?.activeDatabaseName ?? "" + return name.isEmpty ? nil : name + } + + private var availableFavoriteTables: [TableInfo] { + let database = activeDatabase + let tablesByKey = Dictionary( + tables.map { (Self.tableKey(schema: $0.schema, name: $0.name), $0) }, + uniquingKeysWith: { first, _ in first } + ) + return favoriteTables.compactMap { entry in + guard entry.database == database else { return nil } + return tablesByKey[Self.tableKey(schema: entry.schema, name: entry.name)] + } + } + + private static func tableKey(schema: String?, name: String) -> String { + "\(schema ?? "")\u{1}\(name)" + } - init(connectionId: UUID, windowState: WindowSidebarState, coordinator: MainContentCoordinator?) { + init(connectionId: UUID, windowState: WindowSidebarState, tables: [TableInfo], coordinator: MainContentCoordinator?) { self.connectionId = connectionId self.windowState = windowState + self.tables = tables self.sidebarState = ConnectionSidebarState.shared(for: connectionId) _viewModel = State(wrappedValue: FavoritesSidebarViewModel(connectionId: connectionId)) self.coordinator = coordinator @@ -33,16 +51,19 @@ internal struct FavoritesTabView: View { var body: some View { Group { let items = viewModel.filteredNodes(searchText: searchText) + let filteredTables = searchText.isEmpty + ? availableFavoriteTables + : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty { + if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty && filteredTables.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty && searchText.isEmpty { + } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && searchText.isEmpty { emptyState - } else if items.isEmpty { + } else if items.isEmpty && filteredTables.isEmpty { noMatchState } else { - favoritesList(items) + favoritesList(items, filteredTables: filteredTables) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -53,6 +74,10 @@ internal struct FavoritesTabView: View { } .onAppear { SQLFolderWatcher.shared.start() + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } + } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } } .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( @@ -133,141 +158,148 @@ internal struct FavoritesTabView: View { // MARK: - List - private func favoritesList(_ items: [FavoriteNode]) -> some View { - List(selection: $sidebarState.selectedFavoriteNodeId) { - nodeRows(items) + private func favoritesList( + _ items: [FavoriteNode], + filteredTables: [TableInfo] + ) -> some View { + List(selection: $sidebarState.selectedFavorite) { + if !filteredTables.isEmpty { + Section(String(localized: "Tables")) { + ForEach(filteredTables) { table in + favoriteTableRow(table: table) + } + } + } + if !items.isEmpty { + Section(String(localized: "Queries")) { + ForEach(items) { node in + FavoriteNodeRow( + node: node, + connectionId: connectionId, + viewModel: viewModel, + isRenameFocused: $isRenameFocused + ) + } + } + } } .listStyle(.sidebar) .scrollContentBackground(.hidden) .onDeleteCommand { deleteSelectedNode() } - .contextMenu(forSelectionType: String.self) { selection in - if let nodeId = selection.first { - contextMenuFor(nodeId: nodeId) + .contextMenu(forSelectionType: FavoriteSelection.self) { selection in + if let selected = selection.first { + contextMenu(for: selected) } } primaryAction: { selection in - guard let nodeId = selection.first else { return } - handlePrimaryAction(nodeId: nodeId) + guard let selected = selection.first else { return } + handlePrimaryAction(selected) } } + private func favoriteTableRow(table: TableInfo) -> some View { + Label { + Text(table.name) + .font(.system(.callout, design: .monospaced)) + } icon: { + Image(systemName: TableRowLogic.iconName(for: table.type)) + .foregroundStyle(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false)) + } + .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) + .accessibilityLabel( + TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) + ) + } + @ViewBuilder - private func contextMenuFor(nodeId: String) -> some View { - if let fav = viewModel.favoriteForNodeId(nodeId) { - favoriteContextMenu(fav) - } else if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { - linkedFavoriteContextMenu(linked) - } else if let folder = viewModel.folderForNodeId(nodeId) { - folderContextMenu(folder) - } else if let linkedFolder = viewModel.linkedFolderForNodeId(nodeId) { - linkedFolderContextMenu(linkedFolder) + private func favoriteTableContextMenu(_ table: TableInfo) -> some View { + Button(String(localized: "Open Table")) { + coordinator?.openTableTab(table) } - } - private func handlePrimaryAction(nodeId: String) { - if let fav = viewModel.favoriteForNodeId(nodeId) { - coordinator?.insertFavorite(fav) - return + Button(String(localized: "Show ER Diagram")) { + coordinator?.showERDiagram() } - if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { - coordinator?.openLinkedFavorite(linked) + + Divider() + + Button(role: .destructive) { + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId) + } label: { + Text(String(localized: "Remove from Favorites")) } } - private func nodeRows(_ items: [FavoriteNode]) -> AnyView { - AnyView(ForEach(items) { node in - switch node.content { - case .favorite(let favorite): - FavoriteRowView(favorite: favorite) - .tag(node.id) - case .folder(let folder): - DisclosureGroup(isExpanded: Binding( - get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: expanded, for: connectionId) - } - )) { - if let children = node.children { - nodeRows(children) - } - } label: { - folderLabel(folder) - } - .tag(node.id) - case .linkedFolder(let linkedFolder): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - nodeRows(children) - } - } label: { - LinkedFolderRowLabel(folder: linkedFolder) - } - .tag(node.id) - case .linkedSubfolder(_, let displayName, _): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - nodeRows(children) - } - } label: { - LinkedSubfolderRowLabel(displayName: displayName) - } - .tag(node.id) - case .linkedFavorite(let linked): - LinkedFavoriteRowView(favorite: linked) - .tag(node.id) - } - }) + private func favoriteTable(database: String?, schema: String?, name: String) -> TableInfo? { + guard database == activeDatabase else { return nil } + return availableFavoriteTables.first { $0.name == name && $0.schema == schema } } - private func linkedSubtreeBinding(_ nodeId: String) -> Binding { - Binding( - get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(nodeId, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setLinkedNodeExpanded(nodeId, expanded: expanded, for: connectionId) + @ViewBuilder + private func contextMenu(for selection: FavoriteSelection) -> some View { + switch selection { + case .table(let database, let schema, let name): + if let table = favoriteTable(database: database, schema: schema, name: name) { + favoriteTableContextMenu(table) } - ) + case .node(let id): + if let node = viewModel.node(forId: id) { + switch node.content { + case .favorite(let favorite): + favoriteContextMenu(favorite) + case .linkedFavorite(let linked): + linkedFavoriteContextMenu(linked) + case .folder(let folder): + folderContextMenu(folder) + case .linkedFolder(let folder): + linkedFolderContextMenu(folder) + case .linkedSubfolder: + EmptyView() + } + } + } } - @ViewBuilder - private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { - if viewModel.renamingFolderId == folder.id { - HStack(spacing: 4) { - Image(systemName: "folder") - TextField( - "", - text: Binding( - get: { viewModel.renamingFolderName }, - set: { viewModel.renamingFolderName = $0 } - ) - ) - .textFieldStyle(.roundedBorder) - .accessibilityLabel(String(localized: "Folder name")) - .focused($isRenameFocused) - .onSubmit { - viewModel.commitRenameFolder(folder) - } - .onExitCommand { - viewModel.renamingFolderId = nil - } - .onAppear { - isRenameFocused = true - } + private func handlePrimaryAction(_ selection: FavoriteSelection) { + switch selection { + case .table(let database, let schema, let name): + if let table = favoriteTable(database: database, schema: schema, name: name) { + coordinator?.openTableTab(table) + } + case .node(let id): + guard let node = viewModel.node(forId: id) else { return } + switch node.content { + case .favorite(let favorite): + coordinator?.insertFavorite(favorite) + case .linkedFavorite(let linked): + coordinator?.openLinkedFavorite(linked) + case .folder, .linkedFolder, .linkedSubfolder: + break } - } else { - Label(folder.name, systemImage: "folder") } } private func deleteSelectedNode() { - guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } - if let fav = viewModel.favoriteForNodeId(nodeId) { - viewModel.deleteFavorite(fav) - return - } - if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { - linkedFileToTrash = linked - showTrashLinkedFileAlert = true + guard let selection = sidebarState.selectedFavorite else { return } + switch selection { + case .table(let database, let schema, let name): + if let table = favoriteTable(database: database, schema: schema, name: name) { + FavoriteTablesStorage.shared.removeFavorite( + name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId + ) + } + case .node(let id): + guard let node = viewModel.node(forId: id) else { return } + switch node.content { + case .favorite(let favorite): + viewModel.deleteFavorite(favorite) + case .linkedFavorite(let linked): + linkedFileToTrash = linked + showTrashLinkedFileAlert = true + case .folder, .linkedFolder, .linkedSubfolder: + break + } } } @@ -501,3 +533,94 @@ internal struct FavoritesTabView: View { } } } + +private struct FavoriteNodeRow: View { + let node: FavoriteNode + let connectionId: UUID + let viewModel: FavoritesSidebarViewModel + @FocusState.Binding var isRenameFocused: Bool + + var body: some View { + switch node.content { + case .favorite(let favorite): + FavoriteRowView(favorite: favorite) + .tag(FavoriteSelection.node(id: node.id)) + case .folder(let folder): + DisclosureGroup(isExpanded: folderExpansion(folder)) { + childRows + } label: { + folderLabel(folder) + } + .tag(FavoriteSelection.node(id: node.id)) + case .linkedFolder(let linkedFolder): + DisclosureGroup(isExpanded: linkedExpansion) { + childRows + } label: { + LinkedFolderRowLabel(folder: linkedFolder) + } + .tag(FavoriteSelection.node(id: node.id)) + case .linkedSubfolder(_, let displayName, _): + DisclosureGroup(isExpanded: linkedExpansion) { + childRows + } label: { + LinkedSubfolderRowLabel(displayName: displayName) + } + .tag(FavoriteSelection.node(id: node.id)) + case .linkedFavorite(let linked): + LinkedFavoriteRowView(favorite: linked) + .tag(FavoriteSelection.node(id: node.id)) + } + } + + @ViewBuilder + private var childRows: some View { + if let children = node.children { + ForEach(children) { child in + FavoriteNodeRow( + node: child, + connectionId: connectionId, + viewModel: viewModel, + isRenameFocused: $isRenameFocused + ) + } + } + } + + private func folderExpansion(_ folder: SQLFavoriteFolder) -> Binding { + Binding( + get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, + set: { FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: $0, for: connectionId) } + ) + } + + private var linkedExpansion: Binding { + Binding( + get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(node.id, for: connectionId) }, + set: { FavoritesExpansionState.shared.setLinkedNodeExpanded(node.id, expanded: $0, for: connectionId) } + ) + } + + @ViewBuilder + private func folderLabel(_ folder: SQLFavoriteFolder) -> some View { + if viewModel.renamingFolderId == folder.id { + HStack(spacing: 4) { + Image(systemName: "folder") + TextField( + "", + text: Binding( + get: { viewModel.renamingFolderName }, + set: { viewModel.renamingFolderName = $0 } + ) + ) + .textFieldStyle(.roundedBorder) + .accessibilityLabel(String(localized: "Folder name")) + .focused($isRenameFocused) + .onSubmit { viewModel.commitRenameFolder(folder) } + .onExitCommand { viewModel.renamingFolderId = nil } + .onAppear { isRenameFocused = true } + } + } else { + Label(folder.name, systemImage: "folder") + } + } +} diff --git a/TablePro/Views/Sidebar/SchemaPickerFooter.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift similarity index 89% rename from TablePro/Views/Sidebar/SchemaPickerFooter.swift rename to TablePro/Views/Sidebar/SchemaPickerControl.swift index dd23e094b..016f4b60b 100644 --- a/TablePro/Views/Sidebar/SchemaPickerFooter.swift +++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift @@ -3,7 +3,7 @@ import os import SwiftUI import TableProPluginKit -struct SchemaPickerFooter: View { +struct SchemaPickerControl: View { let connectionId: UUID let databaseType: DatabaseType @@ -34,19 +34,16 @@ struct SchemaPickerFooter: View { var body: some View { if allSchemas.count > 1 { - VStack(spacing: 0) { - Divider() - SchemaPopUpButton( - title: currentSchema ?? String(localized: "Select schema"), - userSchemas: userSchemas, - systemSchemas: visibleSystemSchemas, - showSystemSchemas: $showSystemSchemas, - currentSchema: currentSchema, - onSelect: select(schema:), - onRefresh: { Task { await schemaService.refresh(connectionId: connectionId) } } - ) - .padding(8) - } + SchemaPopUpButton( + title: currentSchema ?? String(localized: "Select schema"), + userSchemas: userSchemas, + systemSchemas: visibleSystemSchemas, + showSystemSchemas: $showSystemSchemas, + currentSchema: currentSchema, + onSelect: select(schema:), + onRefresh: { Task { await schemaService.refresh(connectionId: connectionId) } } + ) + .fixedSize() .onReceive(AppEvents.shared.currentSchemaChanged) { changedId in if changedId == connectionId { schemaVersion &+= 1 diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index ccd6ab942..7bf7b2c58 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -1,10 +1,3 @@ -// -// SidebarContextMenu.swift -// TablePro -// -// Context menu for sidebar table rows and empty space. -// - import SwiftUI import TableProPluginKit @@ -17,7 +10,6 @@ enum SidebarContextMenuLogic { clickedTable?.type == .view } - /// True when the object cannot be modified via DML (INSERT/UPDATE/DELETE). static func isReadOnlyKind(_ type: TableInfo.TableType?) -> Bool { switch type { case .view, .materializedView, .foreignTable, .systemTable: @@ -45,9 +37,17 @@ enum SidebarContextMenuLogic { case .table, .none: return String(localized: "Delete") } } + + static func maintenanceGroupEnabled( + isReadOnly: Bool, + hasSelection: Bool, + supportedOperations: [String] + ) -> Bool { + guard !isReadOnly, hasSelection else { return false } + return !supportedOperations.isEmpty + } } -/// Unified context menu for sidebar — used for both table rows and empty space struct SidebarContextMenu: View { let clickedTable: TableInfo? let selectedTables: Set @@ -72,11 +72,6 @@ struct SidebarContextMenu: View { } var body: some View { - Button("Create New Table...") { - coordinator?.createNewTable() - } - .disabled(isReadOnly) - Button("Create New View...") { coordinator?.createView() } @@ -100,7 +95,7 @@ struct SidebarContextMenu: View { } .disabled(clickedTable == nil) - Button(String(localized: "View ER Diagram")) { + Button(String(localized: "Show ER Diagram")) { coordinator?.showERDiagram() } @@ -126,9 +121,14 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - if let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty, hasSelection { + let maintenanceOps = coordinator?.supportedMaintenanceOperations() ?? [] + if SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: isReadOnly, + hasSelection: hasSelection, + supportedOperations: maintenanceOps + ) { Menu(String(localized: "Maintenance")) { - ForEach(ops, id: \.self) { op in + ForEach(maintenanceOps, id: \.self) { op in Button(op) { if let table = clickedTable?.name { coordinator?.showMaintenanceSheet(operation: op, tableName: table) @@ -136,7 +136,6 @@ struct SidebarContextMenu: View { } } } - .disabled(isReadOnly) } Divider() diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cc072cdbf..73f5d8736 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,9 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared + @State private var favoriteTables: Set = [] + @State private var recentTables: [RecentTablesStore.Entry] = [] + @State private var settingsManager = AppSettingsManager.shared var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -100,16 +103,15 @@ struct SidebarView: View { case .tables: VStack(spacing: 0) { tablesContent - if supportsSchemaFooter { - Divider() - SchemaPickerFooter(connectionId: connectionId, databaseType: viewModel.databaseType) - } + Divider() + tablesBottomBar } case .favorites: if let coordinator { FavoritesTabView( connectionId: connectionId, windowState: coordinator.windowSidebarState, + tables: tables, coordinator: coordinator ) } else { @@ -156,6 +158,35 @@ struct SidebarView: View { } } + // MARK: - Bottom Bar + + private var tablesBottomBar: some View { + HStack(spacing: 8) { + createObjectMenu + Spacer() + if supportsSchemaFooter { + SchemaPickerControl(connectionId: connectionId, databaseType: viewModel.databaseType) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + + private var createObjectMenu: some View { + Menu { + Button(String(localized: "New Table")) { coordinator?.createNewTable() } + Button(String(localized: "New View")) { coordinator?.createView() } + } label: { + Image(systemName: "plus") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(String(localized: "Create a new table or view")) + .disabled(coordinator?.safeModeLevel.blocksAllWrites ?? true) + .accessibilityIdentifier("sidebar-create-table") + } + @ViewBuilder private var hierarchicalContent: some View { switch schemaService.state(for: connectionId) { @@ -232,8 +263,93 @@ struct SidebarView: View { // MARK: - Table List + private var filteredRecents: [RecentTablesStore.Entry] { + let search = viewModel.searchText + guard !search.isEmpty else { return recentTables } + return recentTables.filter { $0.name.localizedCaseInsensitiveContains(search) } + } + + private var activeDatabase: String? { + let name = coordinator?.activeDatabaseName ?? "" + return name.isEmpty ? nil : name + } + + private func tableInfo(forRecent entry: RecentTablesStore.Entry) -> TableInfo { + if let match = tables.first(where: { $0.name == entry.name && $0.schema == entry.schema }) { + return match + } + return TableInfo(name: entry.name, type: entry.type, rowCount: nil, schema: entry.schema) + } + + private func isFavorite(_ table: TableInfo) -> Bool { + favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry( + connectionId: connectionId, + database: activeDatabase, + schema: table.schema, + name: table.name + )) + } + + private func toggleFavorite(_ table: TableInfo) { + FavoriteTablesStorage.shared.toggle( + name: table.name, + schema: table.schema, + database: activeDatabase, + connectionId: connectionId + ) + } + + private func reloadRecentTables() { + guard settingsManager.general.showRecentTables else { + recentTables = [] + return + } + recentTables = RecentTablesStore.shared.entries( + connectionID: connectionId, + database: activeDatabase + ) + } + + @ViewBuilder + private var recentSection: some View { + let recents = filteredRecents + if settingsManager.general.showRecentTables, !recents.isEmpty { + Section(isExpanded: $viewModel.isRecentsExpanded) { + ForEach(recents) { entry in + let info = tableInfo(forRecent: entry) + TableRow( + table: info, + isPendingTruncate: pendingTruncates.contains(info.name), + isPendingDelete: pendingDeletes.contains(info.name), + isFavorite: isFavorite(info), + onToggleFavorite: { toggleFavorite(info) } + ) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture { + onDoubleClick?(info) + } + .contextMenu { + SidebarContextMenu( + clickedTable: info, + selectedTables: windowState.selectedTables, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + } + } header: { + Text(String(localized: "Recent")) + } + } + } + private var tableList: some View { List(selection: selectedTablesBinding) { + recentSection + ForEach(SidebarObjectKind.allCases, id: \.self) { kind in sectionView(for: kind) } @@ -267,6 +383,19 @@ struct SidebarView: View { .onExitCommand { windowState.selectedTables.removeAll() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + } + .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in + reloadRecentTables() + } + .onChange(of: settingsManager.general.showRecentTables) { _, _ in + reloadRecentTables() + } + .onAppear { + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + reloadRecentTables() + } } // MARK: - Section View @@ -308,7 +437,9 @@ struct SidebarView: View { TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + isPendingDelete: pendingDeletes.contains(table.name), + isFavorite: isFavorite(table), + onToggleFavorite: { toggleFavorite(table) } ) .tag(table) .contextMenu { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6c23ef681..0affc9a3a 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -26,32 +26,34 @@ enum TableRowLogic { } } - static func accessibilityLabel(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool) -> String { + static func accessibilityLabel(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool, isFavorite: Bool = false) -> String { let kind = accessibilityKindLabel(for: table.type) var label = String(format: String(localized: "%@: %@"), kind, table.name) if isPendingDelete { label += ", " + String(localized: "pending delete") } else if isPendingTruncate { label += ", " + String(localized: "pending truncate") + } else if isFavorite { + label += ", " + String(localized: "favorite") } return label } static func iconColor(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool) -> Color { - if isPendingDelete { return .red } - if isPendingTruncate { return .orange } + if isPendingDelete { return Color(nsColor: .systemRed) } + if isPendingTruncate { return Color(nsColor: .systemOrange) } switch table.type { - case .table: return .blue - case .view: return .purple + case .table: return Color(nsColor: .systemBlue) + case .view: return Color(nsColor: .systemPurple) case .materializedView: return Color(nsColor: .systemTeal) case .foreignTable: return Color(nsColor: .systemIndigo) - case .systemTable: return .gray + case .systemTable: return Color(nsColor: .systemGray) } } static func textColor(isPendingDelete: Bool, isPendingTruncate: Bool) -> Color { - if isPendingDelete { return .red } - if isPendingTruncate { return .orange } + if isPendingDelete { return Color(nsColor: .systemRed) } + if isPendingTruncate { return Color(nsColor: .systemOrange) } return .primary } } @@ -60,6 +62,10 @@ struct TableRow: View { let table: TableInfo let isPendingTruncate: Bool let isPendingDelete: Bool + var isFavorite: Bool = false + var onToggleFavorite: (() -> Void)? + + @State private var isHovered = false private var iconColor: Color { TableRowLogic.iconColor(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) @@ -70,32 +76,81 @@ struct TableRow: View { } var body: some View { - Label { - Text(table.name) - .font(.system(.callout, design: .monospaced)) - .lineLimit(1) - .sidebarTint(textColor) - } icon: { - ZStack(alignment: .bottomTrailing) { - Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(iconColor) - .frame(width: 14) - - if isPendingDelete { - Image(systemName: "minus.circle.fill") - .font(.caption) - .sidebarTint(.red) - .offset(x: 4, y: 4) - } else if isPendingTruncate { - Image(systemName: "exclamationmark.circle.fill") - .font(.caption) - .sidebarTint(.orange) - .offset(x: 4, y: 4) + HStack(spacing: 6) { + Label { + Text(table.name) + .font(.system(.callout, design: .monospaced)) + .lineLimit(1) + .sidebarTint(textColor) + } icon: { + ZStack(alignment: .bottomTrailing) { + Image(systemName: TableRowLogic.iconName(for: table.type)) + .sidebarTint(iconColor) + .frame(width: 14) + + if isPendingDelete { + Image(systemName: "minus.circle.fill") + .font(.caption) + .sidebarTint(.red) + .offset(x: 4, y: 4) + } else if isPendingTruncate { + Image(systemName: "exclamationmark.circle.fill") + .font(.caption) + .sidebarTint(.orange) + .offset(x: 4, y: 4) + } + } + } + + Spacer(minLength: 4) + + if let onToggleFavorite { + let starVisible = isFavorite || isHovered + Button(action: onToggleFavorite) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(isFavorite ? Color.yellow : Color.secondary) + .contentShape(Rectangle()) + .frame(width: 20, height: 20) } + .buttonStyle(.plain) + .opacity(starVisible ? 1 : 0) + .allowsHitTesting(starVisible) + .accessibilityHidden(true) + .help(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) } } .padding(.vertical, 4) + .onHover { isHovered = $0 } .accessibilityElement(children: .combine) - .accessibilityLabel(TableRowLogic.accessibilityLabel(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate)) + .accessibilityLabel( + TableRowLogic.accessibilityLabel( + table: table, + isPendingDelete: isPendingDelete, + isPendingTruncate: isPendingTruncate, + isFavorite: isFavorite + ) + ) + .modifier(FavoriteAccessibilityAction(isFavorite: isFavorite, toggle: onToggleFavorite)) + } +} + +private struct FavoriteAccessibilityAction: ViewModifier { + let isFavorite: Bool + let toggle: (() -> Void)? + + func body(content: Content) -> some View { + if let toggle { + content.accessibilityAction( + named: isFavorite + ? Text("Remove from Favorites") + : Text("Add to Favorites"), + toggle + ) + } else { + content + } } } diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift new file mode 100644 index 000000000..ac6201789 --- /dev/null +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -0,0 +1,30 @@ +// +// DatabaseManagerTunnelTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("DatabaseManager tunnel rewrite") +@MainActor +struct DatabaseManagerTunnelTests { + @Test("Tunneled connection rewrites the endpoint and keeps the password source") + func tunnelPreservesPasswordSource() { + var connection = DatabaseConnection( + name: "tunneled", + host: "db.internal", + port: 5_432, + type: .postgresql + ) + connection.passwordSource = .env(variable: "DB_PASS") + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 61_234) + + #expect(tunneled.host == "127.0.0.1") + #expect(tunneled.port == 61_234) + #expect(tunneled.passwordSource == .env(variable: "DB_PASS")) + } +} diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 22f13b0aa..93b537536 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -66,6 +66,20 @@ struct ConnectionStoragePersistenceTests { #expect(loaded.first?.name == "Round Trip Test") } + @Test("duplicating a connection preserves its password source") + func duplicatePreservesPasswordSource() { + var connection = DatabaseConnection(name: "Source", type: .postgresql) + connection.passwordSource = .file(path: "~/.config/tablepro/db.pw") + storage.addConnection(connection) + + let duplicate = storage.duplicateConnection(connection) + #expect(duplicate.id != connection.id) + #expect(duplicate.passwordSource == .file(path: "~/.config/tablepro/db.pw")) + + let reloaded = storage.loadConnections().first { $0.id == duplicate.id } + #expect(reloaded?.passwordSource == .file(path: "~/.config/tablepro/db.pw")) + } + @Test("connections default to not favorited") func defaultsToNotFavorited() { let connection = DatabaseConnection(name: "Plain Test") diff --git a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift new file mode 100644 index 000000000..5f47f85a4 --- /dev/null +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -0,0 +1,111 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("FavoriteTablesStorage") +struct FavoriteTablesStorageTests { + private func makeStorage() throws -> (FavoriteTablesStorage, SyncMetadataStorage) { + let favoritesSuite = "FavoriteTablesStorageTests.favorites.\(UUID().uuidString)" + let syncSuite = "FavoriteTablesStorageTests.sync.\(UUID().uuidString)" + let favoritesDefaults = try #require(UserDefaults(suiteName: favoritesSuite)) + let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) + favoritesDefaults.removePersistentDomain(forName: favoritesSuite) + syncDefaults.removePersistentDomain(forName: syncSuite) + + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + let storage = FavoriteTablesStorage(userDefaults: favoritesDefaults, syncTracker: tracker) + return (storage, metadata) + } + + @Test("Add favorite marks stable sync ID dirty") + func addMarksDirty() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connId) + + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, database: nil, schema: nil, name: "users") + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(storage.loadFavorites() == [entry]) + #expect(metadata.dirtyIds(for: .tableFavorite) == [id]) + } + + @Test("Remove favorite creates sync tombstone") + func removeCreatesTombstone() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connId) + storage.removeFavorite(name: "users", schema: nil, database: nil, connectionId: connId) + + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, database: nil, schema: nil, name: "users") + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(storage.loadFavorites().isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + #expect(metadata.tombstones(for: .tableFavorite).contains { $0.id == id }) + } + + @Test("Remote apply helpers do not track local sync changes") + func withoutSyncDoesNotTrackChanges() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, database: nil, schema: nil, name: "orders") + storage.addFavoriteWithoutSync(entry) + storage.removeFavoriteWithoutSync(entry) + + #expect(storage.loadFavorites().isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + #expect(metadata.tombstones(for: .tableFavorite).isEmpty) + } + + @Test("Favorites scoped per connection: same name in different connections are distinct") + func favoritesAreConnectionScoped() throws { + let (storage, _) = try makeStorage() + let connA = UUID() + let connB = UUID() + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connA) + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connB) + + let favA = storage.favorites(for: connA) + let favB = storage.favorites(for: connB) + #expect(favA.count == 1) + #expect(favB.count == 1) + #expect(favA.first?.connectionId == connA) + #expect(favB.first?.connectionId == connB) + #expect(storage.loadFavorites().count == 2) + } + + @Test("Schema-qualified and unqualified same-named tables are distinct") + func schemaQualifiedIsDistinct() throws { + let (storage, _) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: "public", database: nil, connectionId: connId) + storage.addFavorite(name: "users", schema: "app", database: nil, connectionId: connId) + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connId) + + #expect(storage.favorites(for: connId).count == 3) + } + + @Test("Same name and schema in different databases are distinct") + func favoritesAreDatabaseScoped() throws { + let (storage, _) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: "public", database: "db1", connectionId: connId) + storage.addFavorite(name: "users", schema: "public", database: "db2", connectionId: connId) + + #expect(storage.favorites(for: connId).count == 2) + #expect(storage.isFavorite(name: "users", schema: "public", database: "db1", connectionId: connId)) + #expect(storage.isFavorite(name: "users", schema: "public", database: "db2", connectionId: connId)) + #expect(!storage.isFavorite(name: "users", schema: "public", database: "db3", connectionId: connId)) + } + + @Test("Toggle on then off leaves no dirty entries") + func toggleOnThenOffNoDirty() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.toggle(name: "orders", schema: nil, database: nil, connectionId: connId) + storage.toggle(name: "orders", schema: nil, database: nil, connectionId: connId) + + #expect(storage.favorites(for: connId).isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + } +} diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 35d1d3be6..b7141d15f 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -14,6 +14,9 @@ final class GroupStorageTests: XCTestCase { private var syncDefaults: UserDefaults! private var syncSuiteName: String! private var storage: GroupStorage! + private var tracker: SyncChangeTracker! + private var connectionStorage: ConnectionStorage! + private var connectionFileURL: URL! override func setUp() { super.setUp() @@ -23,18 +26,38 @@ final class GroupStorageTests: XCTestCase { syncSuiteName = "com.TablePro.tests.Sync.\(unique)" syncDefaults = UserDefaults(suiteName: syncSuiteName)! let metadata = SyncMetadataStorage(userDefaults: syncDefaults) - let tracker = SyncChangeTracker(metadataStorage: metadata) - storage = GroupStorage(userDefaults: defaults, syncTracker: tracker) + tracker = SyncChangeTracker(metadataStorage: metadata) + connectionFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("group-connections_\(unique).json") + try? FileManager.default.createDirectory( + at: connectionFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + connectionStorage = ConnectionStorage( + fileURL: connectionFileURL, + userDefaults: defaults, + syncTracker: tracker + ) + storage = GroupStorage( + userDefaults: defaults, + syncTracker: tracker, + connectionStorage: self.connectionStorage + ) } override func tearDown() { defaults.removePersistentDomain(forName: suiteName) syncDefaults.removePersistentDomain(forName: syncSuiteName) + try? FileManager.default.removeItem(at: connectionFileURL) defaults = nil suiteName = nil syncDefaults = nil syncSuiteName = nil storage = nil + tracker = nil + connectionStorage = nil + connectionFileURL = nil super.tearDown() } @@ -129,6 +152,22 @@ final class GroupStorageTests: XCTestCase { XCTAssertEqual(loaded[0].name, "Prod") } + func testDeleteGroupClearsMembershipAndMarksConnectionDirtyForSync() { + let group = ConnectionGroup(name: "Dev", color: .green) + storage.saveGroups([group]) + + let connection = DatabaseConnection(name: "Grouped", groupId: group.id) + connectionStorage.addConnection(connection) + tracker.clearAllDirty(.connection) + + storage.deleteGroup(group) + + let reloaded = connectionStorage.loadConnections() + XCTAssertEqual(reloaded.count, 1) + XCTAssertNil(reloaded[0].groupId) + XCTAssertTrue(tracker.dirtyRecords(for: .connection).contains(connection.id.uuidString)) + } + // MARK: - Lookup func testGroupForId() { diff --git a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift new file mode 100644 index 000000000..5327866c0 --- /dev/null +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -0,0 +1,68 @@ +import CloudKit +import Foundation +@testable import TablePro +import Testing + +@Suite("SyncRecordMapper favorite tables") +struct SyncRecordMapperFavoriteTableTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + @Test("Table favorite record round trips all fields") + func tableFavoriteRoundTrip() throws { + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry( + connectionId: connId, database: "shop", schema: "public", name: "users" + ) + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) + + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(record.recordType == SyncRecordType.tableFavorite.rawValue) + #expect(record.recordID.recordName == "FavoriteTable_\(id)") + #expect(record["name"] as? String == "users") + #expect(record["connectionId"] as? String == connId.uuidString) + #expect(record["database"] as? String == "shop") + #expect(record["schema"] as? String == "public") + + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @Test("Table favorite without database or schema round trips correctly") + func tableFavoriteNoDatabaseNoSchemaRoundTrip() throws { + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry( + connectionId: connId, database: nil, schema: nil, name: "orders" + ) + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) + + #expect(record["database"] == nil) + #expect(record["schema"] == nil) + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @Test("Same name and schema in different databases have distinct sync IDs") + func distinctSyncIdsAcrossDatabases() { + let connId = UUID() + let entryA = FavoriteTablesStorage.FavoriteEntry( + connectionId: connId, database: "db1", schema: "public", name: "users" + ) + let entryB = FavoriteTablesStorage.FavoriteEntry( + connectionId: connId, database: "db2", schema: "public", name: "users" + ) + #expect(FavoriteTablesStorage.syncId(for: entryA) != FavoriteTablesStorage.syncId(for: entryB)) + } + + @Test("Two entries with same name but different connections have distinct sync IDs") + func distinctSyncIds() { + let connA = UUID() + let connB = UUID() + let entryA = FavoriteTablesStorage.FavoriteEntry( + connectionId: connA, database: nil, schema: nil, name: "users" + ) + let entryB = FavoriteTablesStorage.FavoriteEntry( + connectionId: connB, database: nil, schema: nil, name: "users" + ) + #expect(FavoriteTablesStorage.syncId(for: entryA) != FavoriteTablesStorage.syncId(for: entryB)) + } +} diff --git a/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift new file mode 100644 index 000000000..bdd8df248 --- /dev/null +++ b/TableProTests/Core/Utilities/PasswordSourceResolverTests.swift @@ -0,0 +1,200 @@ +// +// PasswordSourceResolverTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PasswordSourceResolver", .serialized) +struct PasswordSourceResolverTests { + @Test("Reads a password from a file") + func fileHappyPath() async throws { + let url = try makeTempFile(contents: "filesecret") + defer { try? FileManager.default.removeItem(at: url) } + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "filesecret") + } + + @Test("Trims a trailing newline from file contents") + func fileTrimsNewline() async throws { + let url = try makeTempFile(contents: "filesecret\n") + defer { try? FileManager.default.removeItem(at: url) } + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "filesecret") + } + + @Test("Expands a tilde in the file path") + func fileExpandsTilde() async throws { + let name = "tablepro_pwtest_\(UUID().uuidString).pw" + let homeURL = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(name) + try "tildesecret".write(to: homeURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: homeURL) } + let password = try await PasswordSourceResolver.resolve(.file(path: "~/\(name)")) + #expect(password == "tildesecret") + } + + @Test("Throws when the file does not exist") + func fileNotFound() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: "/nonexistent/tablepro/\(UUID().uuidString)")) + } + } + + @Test("Throws when the file is empty") + func fileEmpty() async throws { + let url = try makeTempFile(contents: "") + defer { try? FileManager.default.removeItem(at: url) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: url.path)) + } + } + + @Test("Throws when the file holds only whitespace") + func fileWhitespaceOnly() async throws { + let url = try makeTempFile(contents: " \n\t ") + defer { try? FileManager.default.removeItem(at: url) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.file(path: url.path)) + } + } + + @Test("Resolves a file with loose permissions instead of refusing") + func fileLoosePermissions() async throws { + let url = try makeTempFile(contents: "loosesecret") + defer { try? FileManager.default.removeItem(at: url) } + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + let password = try await PasswordSourceResolver.resolve(.file(path: url.path)) + #expect(password == "loosesecret") + } + + @Test("Reads a password from an environment variable") + func envHappyPath() async throws { + let name = uniqueEnvName() + setenv(name, "envsecret", 1) + defer { unsetenv(name) } + let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + #expect(password == "envsecret") + } + + @Test("Trims whitespace from an environment variable value") + func envTrimsWhitespace() async throws { + let name = uniqueEnvName() + setenv(name, " envsecret ", 1) + defer { unsetenv(name) } + let password = try await PasswordSourceResolver.resolve(.env(variable: name)) + #expect(password == "envsecret") + } + + @Test("Throws when the environment variable is not set") + func envNotSet() async { + let name = uniqueEnvName() + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.env(variable: name)) + } + } + + @Test("Throws when the environment variable is empty") + func envEmpty() async { + let name = uniqueEnvName() + setenv(name, "", 1) + defer { unsetenv(name) } + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.env(variable: name)) + } + } + + @Test("Reads a password from command stdout") + func commandHappyPath() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "printf 'cmdsecret'")) + #expect(password == "cmdsecret") + } + + @Test("Trims a trailing newline from command stdout") + func commandTrimsNewline() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "echo cmdsecret")) + #expect(password == "cmdsecret") + } + + @Test("Preserves interior spaces in command stdout") + func commandPreservesSpaces() async throws { + let password = try await PasswordSourceResolver.resolve(.command(shell: "printf 'a b c'")) + #expect(password == "a b c") + } + + @Test("Throws with exit code and stderr on non-zero exit") + func commandNonZeroExit() async { + do { + _ = try await PasswordSourceResolver.resolveCommand(shell: "echo boom >&2; exit 7", timeoutSeconds: 30) + Issue.record("Expected resolveCommand to throw") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case let .commandFailed(exitCode, stderr) = error else { + Issue.record("Expected commandFailed, got \(error)") + return + } + #expect(exitCode == 7) + #expect(stderr.contains("boom")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Throws when command produces empty stdout") + func commandEmptyOutput() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.command(shell: "true")) + } + } + + @Test("Rejects command output containing a NUL byte") + func commandRejectsNul() async { + await #expect(throws: PasswordSourceResolver.ResolutionError.self) { + try await PasswordSourceResolver.resolve(.command(shell: "printf 'a\\000b'")) + } + } + + @Test("Throws when command output exceeds the size cap") + func commandOutputTooLarge() async { + do { + _ = try await PasswordSourceResolver.resolveCommand( + shell: "head -c 2000000 /dev/zero | tr '\\0' 'a'", + timeoutSeconds: 30 + ) + Issue.record("Expected resolveCommand to reject oversized output") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case .outputTooLarge = error else { + Issue.record("Expected outputTooLarge, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test("Times out a slow command") + func commandTimesOut() async { + do { + _ = try await PasswordSourceResolver.resolveCommand(shell: "sleep 5", timeoutSeconds: 1) + Issue.record("Expected resolveCommand to time out") + } catch let error as PasswordSourceResolver.ResolutionError { + guard case .commandTimedOut = error else { + Issue.record("Expected commandTimedOut, got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private func makeTempFile(contents: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro_pwtest_\(UUID().uuidString).pw") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } + + private func uniqueEnvName() -> String { + "TABLEPRO_PWTEST_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" + } +} diff --git a/TableProTests/Models/GeneralSettingsTests.swift b/TableProTests/Models/GeneralSettingsTests.swift new file mode 100644 index 000000000..6c34af50d --- /dev/null +++ b/TableProTests/Models/GeneralSettingsTests.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("GeneralSettings.showRecentTables") +struct GeneralSettingsTests { + @Test("Defaults to off") + func defaultsOff() { + #expect(GeneralSettings.default.showRecentTables == false) + #expect(GeneralSettings().showRecentTables == false) + } + + @Test("Decoding settings without the key keeps recent tables off") + func decodesMissingKeyAsOff() throws { + let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) + #expect(decoded.showRecentTables == false) + } + + @Test("Round-trips when enabled") + func roundTripsEnabled() throws { + var settings = GeneralSettings() + settings.showRecentTables = true + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) + #expect(decoded.showRecentTables == true) + } +} diff --git a/TableProTests/Models/PasswordSourceCodableTests.swift b/TableProTests/Models/PasswordSourceCodableTests.swift new file mode 100644 index 000000000..306c9c2dc --- /dev/null +++ b/TableProTests/Models/PasswordSourceCodableTests.swift @@ -0,0 +1,70 @@ +// +// PasswordSourceCodableTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("PasswordSource Codable") +struct PasswordSourceCodableTests { + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + @Test("Encodes each kind with the documented field names") + func encodesDocumentedFieldNames() throws { + #expect(try shape(.file(path: "p")) == ["kind": "file", "path": "p"]) + #expect(try shape(.env(variable: "V")) == ["kind": "env", "variable": "V"]) + #expect(try shape(.command(shell: "s")) == ["kind": "command", "shell": "s"]) + } + + private func shape(_ source: PasswordSource) throws -> [String: String] { + try decoder.decode([String: String].self, from: encoder.encode(source)) + } + + @Test("Round-trips all three kinds") + func roundTrips() throws { + let sources: [PasswordSource] = [ + .file(path: "~/db.pw"), + .env(variable: "DB_PASS"), + .command(shell: "op read op://vault/db/password"), + ] + for source in sources { + let decoded = try decoder.decode(PasswordSource.self, from: encoder.encode(source)) + #expect(decoded == source) + } + } + + @Test("Decodes the documented JSON shape") + func decodesDocumentedShape() throws { + let json = #"{"kind":"file","path":"~/.config/tablepro/secrets/feature-x.pw"}"# + let data = try #require(json.data(using: .utf8)) + let decoded = try decoder.decode(PasswordSource.self, from: data) + #expect(decoded == .file(path: "~/.config/tablepro/secrets/feature-x.pw")) + } + + @Test("Throws on an unknown kind") + func throwsOnUnknownKind() throws { + let json = #"{"kind":"vault","path":"x"}"# + let data = try #require(json.data(using: .utf8)) + #expect(throws: DecodingError.self) { + _ = try decoder.decode(PasswordSource.self, from: data) + } + } + + @Test("Round-trips through DatabaseConnection") + func roundTripsThroughConnection() throws { + var connection = DatabaseConnection(name: "worktree", type: .postgresql) + connection.passwordSource = .command(shell: "op read op://vault/feature-x/password") + let decoded = try decoder.decode(DatabaseConnection.self, from: encoder.encode(connection)) + #expect(decoded.passwordSource == .command(shell: "op read op://vault/feature-x/password")) + } + + @Test("A connection without a password source decodes to nil") + func absentPasswordSourceIsNil() throws { + let connection = DatabaseConnection(name: "plain", type: .mysql) + let decoded = try decoder.decode(DatabaseConnection.self, from: encoder.encode(connection)) + #expect(decoded.passwordSource == nil) + } +} diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift new file mode 100644 index 000000000..78ab95a10 --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("RecentTablesStore") +@MainActor +struct RecentTablesStoreTests { + private func makeStore() -> RecentTablesStore { + RecentTablesStore() + } + + private func makeTable(_ name: String, schema: String? = nil) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + @Test("Push inserts entry at the front") + func pushInsertsAtFront() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "db", table: makeTable("b")) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.map(\.name) == ["b", "a"]) + } + + @Test("Push dedupes by table id and bumps to front") + func pushDedupes() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "db", table: makeTable("b")) + store.push(connectionID: conn, database: "db", table: makeTable("a")) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.map(\.name) == ["a", "b"]) + } + + @Test("Push caps list at 10 entries") + func pushCaps() { + let store = makeStore() + let conn = UUID() + for index in 0..<15 { + store.push(connectionID: conn, database: "db", table: makeTable("t\(index)")) + } + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.count == store.cappedSize) + #expect(entries.first?.name == "t14") + #expect(entries.last?.name == "t5") + } + + @Test("Entries isolated per (connection, database) key") + func entriesIsolated() { + let store = makeStore() + let connA = UUID() + let connB = UUID() + store.push(connectionID: connA, database: "db", table: makeTable("alpha")) + store.push(connectionID: connB, database: "db", table: makeTable("beta")) + store.push(connectionID: connA, database: "other", table: makeTable("gamma")) + + #expect(store.entries(connectionID: connA, database: "db").map(\.name) == ["alpha"]) + #expect(store.entries(connectionID: connB, database: "db").map(\.name) == ["beta"]) + #expect(store.entries(connectionID: connA, database: "other").map(\.name) == ["gamma"]) + } + + @Test("Schema-qualified table is distinct from same-name unqualified") + func schemaDistinct() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("users", schema: "public")) + store.push(connectionID: conn, database: "db", table: makeTable("users", schema: nil)) + let entries = store.entries(connectionID: conn, database: "db") + #expect(entries.count == 2) + } + + @Test("Clear removes all entries for a key") + func clearKey() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: "db", table: makeTable("a")) + store.push(connectionID: conn, database: "other", table: makeTable("b")) + store.clear(connectionID: conn, database: "db") + #expect(store.entries(connectionID: conn, database: "db").isEmpty) + #expect(store.entries(connectionID: conn, database: "other").map(\.name) == ["b"]) + } + + @Test("Nil database key is distinct from empty-string database") + func nilDatabaseDistinctFromEmpty() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: nil, table: makeTable("sqlite_table")) + store.push(connectionID: conn, database: "postgres", table: makeTable("pg_table")) + #expect(store.entries(connectionID: conn, database: nil).map(\.name) == ["sqlite_table"]) + #expect(store.entries(connectionID: conn, database: "postgres").map(\.name) == ["pg_table"]) + } +} diff --git a/TableProTests/ViewModels/FavoriteSelectionTests.swift b/TableProTests/ViewModels/FavoriteSelectionTests.swift new file mode 100644 index 000000000..24d2215f4 --- /dev/null +++ b/TableProTests/ViewModels/FavoriteSelectionTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("FavoriteSelection") +struct FavoriteSelectionTests { + private func roundTrip(_ selection: FavoriteSelection) -> FavoriteSelection? { + FavoriteSelection(rawValue: selection.rawValue) + } + + @Test("Table with database and schema round trips") + func tableFull() { + let selection = FavoriteSelection.table(database: "shop", schema: "public", name: "users") + #expect(roundTrip(selection) == selection) + } + + @Test("Table without database or schema round trips") + func tableBare() { + let selection = FavoriteSelection.table(database: nil, schema: nil, name: "users") + #expect(roundTrip(selection) == selection) + } + + @Test("Node round trips") + func node() { + let selection = FavoriteSelection.node(id: "fav-\(UUID().uuidString)") + #expect(roundTrip(selection) == selection) + } + + @Test("Same table in different databases is distinct") + func databaseScoped() { + let db1 = FavoriteSelection.table(database: "db1", schema: "public", name: "users") + let db2 = FavoriteSelection.table(database: "db2", schema: "public", name: "users") + #expect(db1 != db2) + #expect(db1.rawValue != db2.rawValue) + } + + @Test("Garbage and legacy raw values decode to nil") + func invalidRawValues() { + #expect(FavoriteSelection(rawValue: "") == nil) + #expect(FavoriteSelection(rawValue: "fav-123") == nil) + #expect(FavoriteSelection(rawValue: "table:public.users") == nil) + #expect(FavoriteSelection(rawValue: "table\u{1}public\u{1}users") == nil) + } +} diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 230298ae4..c627f71c0 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -175,4 +175,42 @@ struct SidebarContextMenuLogicTests { let clickedTable: TableInfo? = TestFixtures.makeTableInfo(name: "users") #expect(clickedTable != nil) } + + // MARK: - Maintenance group disabled rule + + @Test("Maintenance group enabled with selection, writable, and supported ops") + func maintenanceEnabledAllConditions() { + #expect(SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: true, + supportedOperations: ["ANALYZE", "OPTIMIZE"] + )) + } + + @Test("Maintenance group disabled when read-only") + func maintenanceDisabledReadOnly() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: true, + hasSelection: true, + supportedOperations: ["ANALYZE"] + )) + } + + @Test("Maintenance group disabled with no selection") + func maintenanceDisabledNoSelection() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: false, + supportedOperations: ["ANALYZE"] + )) + } + + @Test("Maintenance group disabled when driver exposes no ops") + func maintenanceDisabledNoOps() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: true, + supportedOperations: [] + )) + } } diff --git a/TableProTests/Views/TableRowLogicTests.swift b/TableProTests/Views/TableRowLogicTests.swift index 0d219b594..e7996ab39 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -6,13 +6,12 @@ // import SwiftUI +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("TableRowLogicTests") struct TableRowLogicTests { - // MARK: - Accessibility Label @Test("Normal table accessibility label") @@ -57,6 +56,13 @@ struct TableRowLogicTests { #expect(label == "View: my_view, pending delete") } + @Test("Favorite table accessibility label") + func accessibilityLabelFavoriteTable() { + let table = TestFixtures.makeTableInfo(name: "users", type: .table) + let label = TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false, isFavorite: true) + #expect(label == "Table: users, favorite") + } + // MARK: - Icon Color @Test("Normal table icon color is system blue") diff --git a/TableProUITests/TableProLaunchUITests.swift b/TableProUITests/TableProLaunchUITests.swift new file mode 100644 index 000000000..3855724e9 --- /dev/null +++ b/TableProUITests/TableProLaunchUITests.swift @@ -0,0 +1,32 @@ +import XCTest + +final class TableProLaunchUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testApplicationLaunchesMainWindow() throws { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + } + + func testMainWindowLaunchesAtOrAboveBaseMinimum() throws { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let frame = window.frame + XCTAssertGreaterThanOrEqual(frame.width, 720, "Window width must be at least the base minimum (720)") + XCTAssertGreaterThanOrEqual(frame.height, 480, "Window height must be at least the base minimum (480)") + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index effa7da8c..64b191690 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -74,6 +74,12 @@ No queries or database content is transmitted. A tab is "clean" when it's a table tab (not query/create), unpinned, no unsaved changes, and no interactions (sort, filter, selection). +### Sidebar + +| Setting | Default | Description | +|---------|---------|-------------| +| **Show recent tables** | Off | Adds a Recent section at the top of the Tables sidebar with the last 10 tables opened per connection and database | + ## AI The **AI** tab configures providers and chat behavior. See [AI Assistant](/features/ai-assistant) for usage. The tab has these sections. diff --git a/docs/docs.json b/docs/docs.json index e1a0367c9..f0ade3654 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -123,7 +123,7 @@ "pages": [ "features/tabs", "features/query-history", - "features/sql-favorites", + "features/favorites", "features/keyboard-shortcuts" ] }, diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 169f41eca..2a6d93284 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -139,7 +139,7 @@ WHERE date_column > | -- NOW(), CURRENT_DATE, etc. ### Favorite Keywords -Favorites you've assigned a keyword to (DB-stored or linked-file `@keyword` frontmatter) appear in the popup as a top-priority match. Type the keyword, accept the suggestion, and the favorite's full SQL replaces the keyword inline. See [SQL Favorites](/features/sql-favorites) for how to assign keywords. +Favorites you've assigned a keyword to (DB-stored or linked-file `@keyword` frontmatter) appear in the popup as a top-priority match. Type the keyword, accept the suggestion, and the favorite's full SQL replaces the keyword inline. See [Favorites](/features/favorites) for how to assign keywords. ### Schema Names diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 2d608f1e9..334ab5b35 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -168,6 +168,24 @@ Use `$VAR` and `${VAR}` in `.tablepro` files. Resolved at connection time. Works with `.env` files, 1Password CLI (`op run`), direnv. +## Password Sources + +Connections in `~/Library/Application Support/TablePro/connections.json` can declare where their password comes from instead of storing it in the Keychain. This helps when a script provisions connections, for example one Docker database per git worktree. The password is resolved at connect time. It is not synced to iCloud, since the path, variable, or command is specific to one Mac. + +Add a `passwordSource` object to the connection: + +```json +{ "passwordSource": { "kind": "file", "path": "~/.config/tablepro/secrets/feature-x.pw" } } +{ "passwordSource": { "kind": "env", "variable": "STAGING_DB_PASSWORD" } } +{ "passwordSource": { "kind": "command", "shell": "op read op://vault/feature-x/password" } } +``` + +- `file`: reads the password from the file. A trailing newline is trimmed. Use `chmod 600` to keep it private. +- `env`: reads the named environment variable. Apps launched from the Dock do not inherit your shell exports, so set it with `launchctl setenv NAME value` or launch TablePro from a terminal. +- `command`: runs the command through `/bin/bash` and reads stdout. Works with `op`, `vault`, `pass`, and `sops`. A trailing newline is trimmed, a non-zero exit fails the connection, and the command has a 30 second timeout. + +When `passwordSource` is set it replaces the Keychain lookup for that connection. If resolution fails, the connection reports the error instead of falling back to the Keychain. + ## File Format JSON. Required fields: `name`, `host`, `type`. diff --git a/docs/features/sql-favorites.mdx b/docs/features/favorites.mdx similarity index 80% rename from docs/features/sql-favorites.mdx rename to docs/features/favorites.mdx index e90ff71ee..4776e8ea7 100644 --- a/docs/features/sql-favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,13 +1,32 @@ --- -title: SQL Favorites -description: Save frequently used queries with optional keyword shortcuts for autocomplete expansion +title: Favorites +description: Mark tables as favorites and save frequently used queries with optional keyword shortcuts --- -# SQL Favorites +# Favorites + +The Tables sidebar can show a **Recent** section at the top with the last 10 tables you opened in the current connection and database (off by default). The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. + +## Table Favorites + +Every table row in the sidebar has a star button at the end. Click it to add or remove the table from favorites. A filled yellow star marks a favorite; an outlined star marks a non-favorite. Favorites: + +- Move to the top of their section +- Appear in the **Tables** group of the Favorites tab + +Double-click a table in the Favorites tab to open it. Right-click it to open the table, open the database's ER diagram, or remove it. + +Favorites are scoped to the connection, database, and schema, and sync through iCloud. A favorite is hidden when its table doesn't exist in the database you're viewing. + +## Recent Tables + +Turn on **Show recent tables** in Settings > General > Sidebar to add a **Recent** section at the top of the Tables sidebar. While it's on, each table you open is added to the list, which keeps the 10 most recent tables per connection and database, with the most recent at the top. Click a row to reopen the table. Recents are kept in memory for the session and clear when you quit. + +## SQL Favorites Save queries you run often. Organize them in folders, assign keyword shortcuts, and expand them inline via autocomplete. -## Creating a Favorite +## Creating an SQL Favorite Three ways to save a favorite: diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index d2a6c6df6..cb00dd1c9 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -1,11 +1,11 @@ --- title: iCloud Sync -description: Sync connections, settings, and SSH profiles across Macs via iCloud (Pro feature) +description: Sync connections, table favorites, settings, and SSH profiles across Macs via iCloud (Pro feature) --- # iCloud Sync -TablePro syncs your connections, groups, settings, and SSH profiles across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. +TablePro syncs your connections, groups, table favorites, settings, and SSH profiles across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. ## What syncs (and what doesn't) @@ -14,6 +14,7 @@ TablePro syncs your connections, groups, settings, and SSH profiles across all y | **Connections** | Yes | Host, port, username, database type, SSH/SSL config | | **Passwords** | Optional | Opt-in via iCloud Keychain (end-to-end encrypted) | | **Groups & Tags** | Yes | Full connection organization, including nested group hierarchy (parent-child relationships and sort order) | +| **Table Favorites** | Yes | Favorited table names shown in the Favorites tab and pinned in table lists | | **App Settings** | Yes | All settings categories (General, Appearance, Editor, Keyboard, AI, Terminal) | | **Linked SQL Folders** | No | Folder paths are per-Mac. Link the same Git repo on each Mac after cloning. Cached file metadata (`linked_sql_index.db`) is also local. | @@ -39,7 +40,7 @@ Open **Settings** (`Cmd+,`) > **Account**, toggle iCloud Sync on, choose which c /> -Each data type has its own toggle: Connections, Groups & Tags, SSH Profiles, and App Settings. +Connections, Groups & Tags, SSH Profiles, and App Settings each have their own toggle. Table favorites sync when iCloud Sync is enabled. ## Excluding individual connections @@ -59,4 +60,3 @@ iCloud Sync requires a Pro license. When a license expires, sync stops but local ## Troubleshooting If no records sync, confirm iCloud is signed in and iCloud Drive is enabled, then click **Sync Now**. For "iCloud account unavailable," sign in via **System Settings** > **Apple Account**. - diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index f00e504a1..ed9a8aae3 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -92,8 +92,8 @@ TablePro opens with a sidebar-style welcome window, in the style of the Xcode la SQLite FTS5-backed history with full-text search. - - Save and reuse named queries. + + Pin tables and save reusable queries. Full shortcut reference. diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 885b84f69..53c64b8e5 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -312,5 +312,4 @@ If you save (`Cmd+S`) while the file has changed externally, TablePro shows a si ### Linked folders -For watching a whole folder of `.sql` files (e.g., a Git repo of team queries), use [Linked SQL Folders](/features/sql-favorites#linked-sql-folders) instead of opening each file by hand. Linked folders update the sidebar within a second of any on-disk change. - +For watching a whole folder of `.sql` files (e.g., a Git repo of team queries), use [Linked SQL Folders](/features/favorites#linked-sql-folders) instead of opening each file by hand. Linked folders update the sidebar within a second of any on-disk change. diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index aced3f9a7..7fbac079c 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -7,6 +7,10 @@ description: Drop, truncate, maintenance, create views, and switch databases fro Right-click tables in the sidebar to drop, truncate, run maintenance, or manage views. Switch between databases on the same connection. +## Create Table + +Click the plus button in the bottom-left of the Tables sidebar and choose **New Table** (or **New View**) to open a create tab. The button is disabled while safe mode blocks writes. + ## Drop Table Permanently deletes a table and all its data. diff --git a/signatures/cla.json b/signatures/cla.json index 4e718a875..6d97b2de7 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -151,6 +151,14 @@ "created_at": "2026-05-28T18:44:37Z", "repoId": 1117891044, "pullRequestNo": 1467 + }, + { + "name": "salmonumbrella", + "id": 182032677, + "comment_id": 4570372303, + "created_at": "2026-05-29T03:56:47Z", + "repoId": 1117891044, + "pullRequestNo": 1476 } ] } \ No newline at end of file