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 33ead989d..35f1aa1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. - The sidebar can show every database on the server as an expandable tree. Switch a connection between the flat list and the tree from the View menu (Sidebar Layout); right-click a database or schema to set it active. Set the default layout for new connections in Settings, General. Applies to MySQL, MariaDB, PostgreSQL, MSSQL, ClickHouse, Redshift; SQLite, Redis, MongoDB, BigQuery keep their existing sidebar. (#139) - 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 Tables sidebar bottom bar uses native macOS styling. The schema switcher is a borderless pull-down menu on the sidebar's own background instead of a wide gray bordered control, matching the Favorites footer, and switching schemas now goes through the same path as the toolbar so filters and the active tab stay in sync. +- 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. diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..5935dc572 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` (per connection + database + schema; iCloud-synced) | ### 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 a2d8da8aa..0570146ca 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/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 56f55c590..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) @@ -585,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() { @@ -689,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 } @@ -827,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 50d7e2b65..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) @@ -328,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/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 a9fd9e4c8..5487c98be 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" : { @@ -13370,7 +13370,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" : { @@ -21386,6 +21391,10 @@ } } }, + "favorite" : { + "comment" : "A label indicating that a table is marked as a favorite.", + "isCommentAutoGenerated" : true + }, "Favorited" : { }, @@ -22468,7 +22477,6 @@ } }, "Format JSON" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27312,7 +27320,6 @@ } }, "Limit" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30236,7 +30243,6 @@ } }, "Next Page (⌘])" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32681,7 +32687,6 @@ } }, "Offset" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33359,6 +33364,10 @@ } } }, + "Open Table" : { + "comment" : "A context menu option to open a table in the main view.", + "isCommentAutoGenerated" : true + }, "Open Table Tab" : { }, @@ -33928,7 +33937,6 @@ } }, "Pagination Settings" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35959,7 +35967,6 @@ } }, "Previous Page (⌘[)" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36630,6 +36637,10 @@ } } }, + "Queries" : { + "comment" : "A section header for the list of queries in the favorites tab.", + "isCommentAutoGenerated" : true + }, "Query" : { "localizations" : { "tr" : { @@ -38509,7 +38520,8 @@ }, "Remove from Favorites" : { - + "comment" : "A button label that deletes a table from the user's favorites.", + "isCommentAutoGenerated" : true }, "Remove from Group" : { "localizations" : { @@ -46374,6 +46386,7 @@ } }, "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46395,6 +46408,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/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/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 c1f5f02f4..d8a2dbe8b 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,35 +13,61 @@ 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 + } - init(connectionId: UUID, windowState: WindowSidebarState, coordinator: MainContentCoordinator?) { + 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, 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 } var body: some View { - Group { - let items = viewModel.filteredNodes(searchText: searchText) - - if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty && searchText.isEmpty { - emptyState - } else if items.isEmpty { - noMatchState - } else { - favoritesList(items) + VStack(spacing: 0) { + Group { + let items = viewModel.filteredNodes(searchText: searchText) + let filteredTables = searchText.isEmpty + ? availableFavoriteTables + : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + + if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty && filteredTables.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && searchText.isEmpty { + emptyState + } else if items.isEmpty && filteredTables.isEmpty { + noMatchState + } else { + favoritesList(items, filteredTables: filteredTables) + } } - } - .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { Divider() bottomToolbar @@ -53,6 +75,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,68 +159,147 @@ 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) } } - @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 favoriteTableRow(table: TableInfo) -> some View { + Label { + Text(table.name) + } icon: { + Image(systemName: TableRowLogic.iconName(for: table.type)) + .sidebarTint(Color.accentColor) } + .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) + .accessibilityLabel( + TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) + ) } - private func handlePrimaryAction(nodeId: String) { - if let fav = viewModel.favoriteForNodeId(nodeId) { - coordinator?.insertFavorite(fav) - return + @ViewBuilder + private func favoriteTableContextMenu(_ table: TableInfo) -> some View { + Button(String(localized: "Open Table")) { + coordinator?.openTableTab(table) + } + + 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 favoriteTable(database: String?, schema: String?, name: String) -> TableInfo? { + guard database == activeDatabase else { return nil } + return availableFavoriteTables.first { $0.name == name && $0.schema == schema } + } + @ViewBuilder - private func nodeRows(_ items: [FavoriteNode]) -> some View { - FavoriteNodeRowsView( - items: items, - connectionId: connectionId, - viewModel: viewModel, - renameFocus: $isRenameFocused - ) + 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() + } + } + } } + 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 + } + } + } 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 + } } } @@ -429,87 +534,69 @@ internal struct FavoritesTabView: View { } } -private struct FavoriteNodeRowsView: View { - let items: [FavoriteNode] +private struct FavoriteNodeRow: View { + let node: FavoriteNode let connectionId: UUID let viewModel: FavoritesSidebarViewModel - let renameFocus: FocusState.Binding + @FocusState.Binding var isRenameFocused: Bool var body: some View { - ForEach(items) { node in - content(for: node) - } - } - - @ViewBuilder - private func content(for node: FavoriteNode) -> some View { switch node.content { case .favorite(let favorite): FavoriteRowView(favorite: favorite) - .tag(node.id) + .tag(FavoriteSelection.node(id: node.id)) case .folder(let folder): - DisclosureGroup(isExpanded: folderExpansionBinding(folder)) { - if let children = node.children { - FavoriteNodeRowsView( - items: children, - connectionId: connectionId, - viewModel: viewModel, - renameFocus: renameFocus - ) - } + DisclosureGroup(isExpanded: folderExpansion(folder)) { + childRows } label: { folderLabel(folder) } - .tag(node.id) + .tag(FavoriteSelection.node(id: node.id)) case .linkedFolder(let linkedFolder): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - FavoriteNodeRowsView( - items: children, - connectionId: connectionId, - viewModel: viewModel, - renameFocus: renameFocus - ) - } + DisclosureGroup(isExpanded: linkedExpansion) { + childRows } label: { LinkedFolderRowLabel(folder: linkedFolder) } - .tag(node.id) + .tag(FavoriteSelection.node(id: node.id)) case .linkedSubfolder(_, let displayName, _): - DisclosureGroup(isExpanded: linkedSubtreeBinding(node.id)) { - if let children = node.children { - FavoriteNodeRowsView( - items: children, - connectionId: connectionId, - viewModel: viewModel, - renameFocus: renameFocus - ) - } + DisclosureGroup(isExpanded: linkedExpansion) { + childRows } label: { LinkedSubfolderRowLabel(displayName: displayName) } - .tag(node.id) + .tag(FavoriteSelection.node(id: node.id)) case .linkedFavorite(let linked): LinkedFavoriteRowView(favorite: linked) - .tag(node.id) + .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 folderExpansionBinding(_ folder: SQLFavoriteFolder) -> Binding { + private func folderExpansion(_ folder: SQLFavoriteFolder) -> Binding { Binding( get: { FavoritesExpansionState.shared.isFolderExpanded(folder.id, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: expanded, for: connectionId) - } + set: { FavoritesExpansionState.shared.setFolderExpanded(folder.id, expanded: $0, for: connectionId) } ) } - private func linkedSubtreeBinding(_ nodeId: String) -> Binding { + private var linkedExpansion: Binding { Binding( - get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(nodeId, for: connectionId) }, - set: { expanded in - FavoritesExpansionState.shared.setLinkedNodeExpanded(nodeId, expanded: expanded, for: connectionId) - } + get: { FavoritesExpansionState.shared.isLinkedNodeExpanded(node.id, for: connectionId) }, + set: { FavoritesExpansionState.shared.setLinkedNodeExpanded(node.id, expanded: $0, for: connectionId) } ) } @@ -527,16 +614,10 @@ private struct FavoriteNodeRowsView: View { ) .textFieldStyle(.roundedBorder) .accessibilityLabel(String(localized: "Folder name")) - .focused(renameFocus) - .onSubmit { - viewModel.commitRenameFolder(folder) - } - .onExitCommand { - viewModel.renamingFolderId = nil - } - .onAppear { - renameFocus.wrappedValue = true - } + .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/SchemaPickerControl.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift new file mode 100644 index 000000000..be61abb3c --- /dev/null +++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift @@ -0,0 +1,76 @@ +import SwiftUI +import TableProPluginKit + +struct SchemaPickerControl: View { + let connectionId: UUID + let databaseType: DatabaseType + let coordinator: MainContentCoordinator? + + @Bindable private var schemaService = SchemaService.shared + @Bindable private var databaseManager = DatabaseManager.shared + @State private var showSystemSchemas = false + + private var currentSchema: String? { + databaseManager.session(for: connectionId)?.currentSchema + } + + private var allSchemas: [String] { + schemaService.schemas(for: connectionId) + } + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: databaseType)) + } + + private var userSchemas: [String] { + allSchemas.filter { !systemSchemas.contains($0) } + } + + private var visibleSystemSchemas: [String] { + allSchemas.filter { systemSchemas.contains($0) } + } + + private var selectedSchema: Binding { + Binding( + get: { currentSchema ?? "" }, + set: { newValue in + guard !newValue.isEmpty, newValue != currentSchema else { return } + Task { await coordinator?.switchSchema(to: newValue) } + } + ) + } + + var body: some View { + if allSchemas.count > 1 { + Menu { + Picker(String(localized: "Schema"), selection: selectedSchema) { + ForEach(userSchemas, id: \.self) { schema in + Text(schema).tag(schema) + } + if showSystemSchemas { + ForEach(visibleSystemSchemas, id: \.self) { schema in + Text(schema).tag(schema) + } + } + } + .pickerStyle(.inline) + .labelsHidden() + + if !visibleSystemSchemas.isEmpty { + Divider() + Toggle(String(localized: "Show System Schemas"), isOn: $showSystemSchemas) + } + + Divider() + Button(String(localized: "Refresh")) { + Task { await schemaService.refresh(connectionId: connectionId) } + } + } label: { + Text(currentSchema ?? String(localized: "Select schema")) + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityLabel(String(localized: "Current schema")) + } + } +} diff --git a/TablePro/Views/Sidebar/SchemaPickerFooter.swift b/TablePro/Views/Sidebar/SchemaPickerFooter.swift deleted file mode 100644 index 78608386a..000000000 --- a/TablePro/Views/Sidebar/SchemaPickerFooter.swift +++ /dev/null @@ -1,187 +0,0 @@ -import AppKit -import os -import SwiftUI -import TableProPluginKit - -struct SchemaPickerFooter: View { - let connectionId: UUID - let databaseType: DatabaseType - - @Bindable private var schemaService = SchemaService.shared - @Bindable private var databaseManager = DatabaseManager.shared - @State private var showSystemSchemas = false - - private var currentSchema: String? { - databaseManager.session(for: connectionId)?.currentSchema - } - - private var allSchemas: [String] { - schemaService.schemas(for: connectionId) - } - - private var systemSchemas: Set { - Set(PluginManager.shared.systemSchemaNames(for: databaseType)) - } - - private var userSchemas: [String] { - allSchemas.filter { !systemSchemas.contains($0) } - } - - private var visibleSystemSchemas: [String] { - allSchemas.filter { systemSchemas.contains($0) } - } - - 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) - } - } - } - - private func select(schema: String) { - guard schema != currentSchema else { return } - Task { - do { - try await DatabaseManager.shared.switchSchema(to: schema, for: connectionId) - } catch { - schemaPickerLogger.error("Schema switch to \(schema, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") - } - } - } -} - -private let schemaPickerLogger = Logger(subsystem: "com.TablePro", category: "SchemaPicker") - -private struct SchemaPopUpButton: NSViewRepresentable { - let title: String - let userSchemas: [String] - let systemSchemas: [String] - @Binding var showSystemSchemas: Bool - let currentSchema: String? - let onSelect: (String) -> Void - let onRefresh: () -> Void - - private var fingerprint: MenuFingerprint { - MenuFingerprint( - title: title, - userSchemas: userSchemas, - systemSchemas: systemSchemas, - showSystemSchemas: showSystemSchemas, - currentSchema: currentSchema - ) - } - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeNSView(context: Context) -> NSPopUpButton { - let button = NSPopUpButton(frame: .zero, pullsDown: true) - button.preferredEdge = .maxY - context.coordinator.lastFingerprint = fingerprint - rebuildMenu(button: button, context: context) - return button - } - - func updateNSView(_ button: NSPopUpButton, context: Context) { - context.coordinator.parent = self - let next = fingerprint - guard context.coordinator.lastFingerprint != next else { return } - context.coordinator.lastFingerprint = next - rebuildMenu(button: button, context: context) - } - - private func rebuildMenu(button: NSPopUpButton, context: Context) { - let menu = NSMenu() - menu.autoenablesItems = false - - menu.addItem(NSMenuItem(title: title, action: nil, keyEquivalent: "")) - - for schema in userSchemas { - menu.addItem(schemaItem(schema, coordinator: context.coordinator)) - } - - if !systemSchemas.isEmpty { - menu.addItem(.separator()) - let toggleItem = NSMenuItem( - title: String(localized: "Show System Schemas"), - action: #selector(Coordinator.toggleSystem(_:)), - keyEquivalent: "" - ) - toggleItem.target = context.coordinator - toggleItem.state = showSystemSchemas ? .on : .off - menu.addItem(toggleItem) - - if showSystemSchemas { - for schema in systemSchemas { - menu.addItem(schemaItem(schema, coordinator: context.coordinator)) - } - } - } - - menu.addItem(.separator()) - let refreshItem = NSMenuItem( - title: String(localized: "Refresh"), - action: #selector(Coordinator.refreshTriggered(_:)), - keyEquivalent: "" - ) - refreshItem.target = context.coordinator - menu.addItem(refreshItem) - - button.menu = menu - } - - private func schemaItem(_ schema: String, coordinator: Coordinator) -> NSMenuItem { - let item = NSMenuItem( - title: schema, - action: #selector(Coordinator.schemaSelected(_:)), - keyEquivalent: "" - ) - item.target = coordinator - item.representedObject = schema - item.state = schema == currentSchema ? .on : .off - return item - } - - @MainActor - final class Coordinator: NSObject { - var parent: SchemaPopUpButton - var lastFingerprint: MenuFingerprint? - - init(parent: SchemaPopUpButton) { - self.parent = parent - } - - @objc func schemaSelected(_ sender: NSMenuItem) { - guard let schema = sender.representedObject as? String else { return } - parent.onSelect(schema) - } - - @objc func toggleSystem(_ sender: NSMenuItem) { - parent.showSystemSchemas.toggle() - } - - @objc func refreshTriggered(_ sender: NSMenuItem) { - parent.onRefresh() - } - } - - struct MenuFingerprint: Equatable { - let title: String - let userSchemas: [String] - let systemSchemas: [String] - let showSystemSchemas: Bool - let currentSchema: String? - } -} diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 78676ed52..bae5a89bb 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -42,6 +42,15 @@ 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 + } } struct SidebarContextMenu: View { @@ -81,11 +90,6 @@ struct SidebarContextMenu: View { } var body: some View { - Button("Create New Table...") { - perform { coordinator?.createNewTable() } - } - .disabled(isReadOnly) - Button("Create New View...") { perform { coordinator?.createView() } } @@ -140,10 +144,14 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - if hasSelection, - let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty { + 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) { perform { if let table = clickedTable?.name { @@ -153,7 +161,6 @@ struct SidebarContextMenu: View { } } } - .disabled(isReadOnly) } if hasSelection { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index a273bb971..9772b16c7 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -10,6 +10,8 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel + @State private var favoriteTables: Set = [] + private var schemaService: SchemaService { SchemaService.shared } var sidebarState: SharedSidebarState @@ -100,16 +102,14 @@ struct SidebarView: View { case .tables: VStack(spacing: 0) { tablesContent - if supportsSchemaFooter { - Divider() - SchemaPickerFooter(connectionId: connectionId, databaseType: viewModel.databaseType) - } + tablesBottomBar } case .favorites: if let coordinator { FavoritesTabView( connectionId: connectionId, windowState: coordinator.windowSidebarState, + tables: tables, coordinator: coordinator ) } else { @@ -158,6 +158,42 @@ struct SidebarView: View { } } + // MARK: - Bottom Bar + + private var tablesBottomBar: some View { + VStack(spacing: 0) { + Divider() + HStack(spacing: 8) { + createObjectMenu + Spacer() + if supportsSchemaFooter { + SchemaPickerControl( + connectionId: connectionId, + databaseType: viewModel.databaseType, + coordinator: coordinator + ) + } + } + .padding(.horizontal, 12) + .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") + } + private var usesDatabaseTree: Bool { PluginManager.shared.supportsDatabaseTree(for: viewModel.databaseType) && sidebarState.sidebarLayout == .tree @@ -252,6 +288,29 @@ struct SidebarView: View { // MARK: - Table List + private var activeDatabase: String? { + let name = coordinator?.activeDatabaseName ?? "" + return name.isEmpty ? nil : name + } + + 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 var tableList: some View { List(selection: selectedTablesBinding) { ForEach(SidebarObjectKind.allCases, id: \.self) { kind in @@ -294,6 +353,12 @@ struct SidebarView: View { .onExitCommand { windowState.selectedTables.removeAll() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + } + .onAppear { + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + } } // MARK: - Section View @@ -335,7 +400,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) } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 667e7c63f..d4f10de94 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -26,13 +26,15 @@ 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 } @@ -42,6 +44,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 @ViewBuilder private var pendingStateBadge: some View { @@ -57,18 +63,67 @@ struct TableRow: View { } var body: some View { - Label { - Text(table.name) - .lineLimit(1) - } icon: { - Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(Color.accentColor) - .frame(width: 16) - .overlay(alignment: .bottomTrailing) { - pendingStateBadge + HStack(spacing: 6) { + Label { + Text(table.name) + .lineLimit(1) + } icon: { + Image(systemName: TableRowLogic.iconName(for: table.type)) + .sidebarTint(Color.accentColor) + .frame(width: 16) + .overlay(alignment: .bottomTrailing) { + pendingStateBadge + } + } + + 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")) + } } + .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/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/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/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 1ff1fc192..023ddcf2c 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -7,11 +7,11 @@ import TableProPluginKit import Testing + @testable import TablePro @Suite("TableRowLogicTests") struct TableRowLogicTests { - // MARK: - Accessibility Label @Test("Normal table accessibility label") @@ -56,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 Name per Kind @Test("Icon name per table kind") 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/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/sql-favorites.mdx b/docs/features/favorites.mdx similarity index 87% rename from docs/features/sql-favorites.mdx rename to docs/features/favorites.mdx index e90ff71ee..c6ddb2688 100644 --- a/docs/features/sql-favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,13 +1,28 @@ --- -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 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. + +## 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.