From e9bd37a07dc1235edc128f7432539eeb248575db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 21:20:40 +0700 Subject: [PATCH 01/28] feat(sidebar): add favorite tables --- CHANGELOG.md | 1 + CLAUDE.md | 5 +- TablePro.xcodeproj/project.pbxproj | 118 +++++++++++++++++ .../xcshareddata/xcschemes/TablePro.xcscheme | 11 ++ TablePro/Core/Services/AppServices.swift | 2 + .../Core/Storage/FavoriteTablesStorage.swift | 96 ++++++++++++++ TablePro/Core/Sync/SyncCoordinator.swift | 76 ++++++++++- TablePro/Core/Sync/SyncRecordMapper.swift | 24 ++++ .../Views/Settings/Sections/SyncSection.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 124 +++++++++++++++--- .../Views/Sidebar/SidebarContextMenu.swift | 13 ++ .../Views/Sidebar/SidebarTableOrdering.swift | 10 ++ TablePro/Views/Sidebar/SidebarView.swift | 13 +- TablePro/Views/Sidebar/TableRowView.swift | 19 ++- .../Storage/FavoriteTablesStorageTests.swift | 58 ++++++++ .../SyncRecordMapperFavoriteTableTests.swift | 24 ++++ .../Views/SidebarTableOrderingTests.swift | 31 +++++ TableProTests/Views/TableRowLogicTests.swift | 10 +- TableProUITests/TableProLaunchUITests.swift | 19 +++ docs/docs.json | 2 +- docs/features/autocomplete.mdx | 2 +- .../{sql-favorites.mdx => favorites.mdx} | 24 +++- docs/features/icloud-sync.mdx | 8 +- docs/features/overview.mdx | 4 +- docs/features/sql-editor.mdx | 3 +- 25 files changed, 654 insertions(+), 45 deletions(-) create mode 100644 TablePro/Core/Storage/FavoriteTablesStorage.swift create mode 100644 TablePro/Views/Sidebar/SidebarTableOrdering.swift create mode 100644 TableProTests/Core/Storage/FavoriteTablesStorageTests.swift create mode 100644 TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift create mode 100644 TableProTests/Views/SidebarTableOrderingTests.swift create mode 100644 TableProUITests/TableProLaunchUITests.swift rename docs/features/{sql-favorites.mdx => favorites.mdx} (87%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898c83693..a3dc6a053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. +- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are pinned to the top of their section, appear in a dedicated Tables group in the Favorites tab, and sync through iCloud. - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) - Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425) diff --git a/CLAUDE.md b/CLAUDE.md index f7c9d6caa..48ba85ccb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ These govern every decision — code, architecture, tooling, and process: 4. **Clean code** — self-explanatory naming, early returns over nested conditionals, small focused functions. No comments in the codebase — code must be self-documenting through clear naming and structure. 5. **Root cause fixes** — don't patch symptoms. Diagnose the underlying issue, add logging to debug if needed, then fix the actual cause. 6. **No hacky solutions** — no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix. -7. **Testability** — if a feature is testable, write tests. When tests fail, fix the source code — never adjust tests to match incorrect output. +7. **Testability** — every testable code change needs unit/function tests, and UI/user-flow changes need UI automation when deterministic. When tests fail, fix the source code — never adjust tests to match incorrect output. 8. **Maintainability** — follow existing patterns but offer refactors when they improve quality. Extract into extensions when approaching size limits. Group by domain logic. 9. **Scalability** — design for the plugin system's open-ended nature. `DatabaseType` is a struct, not an enum. All switches need `default:`. @@ -52,6 +52,7 @@ swiftformat . # Format code xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests # DMG scripts/create-dmg.sh @@ -223,7 +224,7 @@ These are **non-negotiable** — never skip them: - Settings changes → `docs/customization/settings.mdx` - Database driver changes → `docs/databases/*.mdx` -4. **Tests**: Write tests for testable features. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior. +4. **Tests**: Every code change must include or update unit/function tests for testable behavior. UI and user-flow changes must also include or update `TableProUITests` UI automation when the flow can run deterministically; if not, state the blocker in the handoff. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior. 5. **Lint after changes**: Run `swiftlint lint --strict` to verify compliance. diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 3da7054b7..f47e72a13 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"> + + + + ? + + init(userDefaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { + self.defaults = userDefaults + self.syncTracker = syncTracker + } + + 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 + } + + func isFavorite(_ name: String) -> Bool { + loadFavorites().contains(name) + } + + func toggle(_ name: String) { + if isFavorite(name) { + removeFavorite(name) + } else { + addFavorite(name) + } + } + + func addFavorite(_ name: String) { + var favorites = loadFavorites() + guard favorites.insert(name).inserted else { return } + persist(favorites) + syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: name)) + } + + func addFavoriteWithoutSync(_ name: String) { + var favorites = loadFavorites() + guard favorites.insert(name).inserted else { return } + persist(favorites) + } + + func removeFavorite(_ name: String) { + var favorites = loadFavorites() + guard favorites.remove(name) != nil else { return } + persist(favorites) + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: name)) + } + + func removeFavoriteWithoutSync(_ name: String) { + var favorites = loadFavorites() + guard favorites.remove(name) != nil else { return } + persist(favorites) + } + + func removeFavoriteWithoutSync(id: String) { + var favorites = loadFavorites() + guard let name = favorites.first(where: { Self.syncId(for: $0) == id }) else { return } + favorites.remove(name) + persist(favorites) + } + + static func syncId(for name: String) -> String { + name.sha256 + } + + 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) + NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) + } +} diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fa0fd3577..f0900caa1 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 tableName in favoriteTables { + changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: tableName)) + } + // 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,8 @@ final class SyncCoordinator { } } + 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 +327,7 @@ final class SyncCoordinator { if settings.syncSettings { changeTracker.clearAllDirty(.settings) } + changeTracker.clearAllDirty(.tableFavorite) // Clear tombstones only for types that were actually pushed if settings.syncConnections { @@ -337,6 +353,9 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) } } + 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 +422,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 +442,8 @@ final class SyncCoordinator { applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) + case SyncRecordType.tableFavorite.rawValue: + applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) default: break } @@ -431,6 +453,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 +472,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 +499,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) @@ -584,8 +612,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 name: String + do { + name = try SyncRecordMapper.favoriteTableName(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: name)) { return false } + let before = services.favoriteTablesStorage.loadFavorites() + services.favoriteTablesStorage.addFavoriteWithoutSync(name) + return before != services.favoriteTablesStorage.loadFavorites() } // MARK: - Observers @@ -688,6 +739,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 +878,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 name in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: name)) { + records.append(SyncRecordMapper.toCKRecord(favoriteTableName: name, 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 b0e236b1c..db00d3d06 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) @@ -323,6 +325,28 @@ struct SyncRecordMapper { record["settingsJson"] as? Data } + // MARK: - Table Favorite + + static func toCKRecord(favoriteTableName name: String, in zone: CKRecordZone.ID) -> CKRecord { + let favoriteId = FavoriteTablesStorage.syncId(for: name) + let recordID = recordID(type: .tableFavorite, id: favoriteId, in: zone) + let record = CKRecord(recordType: SyncRecordType.tableFavorite.rawValue, recordID: recordID) + + record["favoriteTableId"] = favoriteId as CKRecordValue + record["name"] = name as CKRecordValue + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func favoriteTableName(from record: CKRecord) throws -> String { + guard let name = record["name"] as? String, !name.isEmpty else { + throw SyncDecodeError.missingRequiredField("name") + } + return name + } + // MARK: - SSH Profile static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index ea9032aed..ad360b469 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) { diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 30e9fc5ba..dd97541e5 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -7,6 +7,7 @@ import SwiftUI internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel + @State private var favoriteTables: [String] = FavoriteTablesStorage.shared.loadFavorites().sorted() @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @State private var linkedFileToTrash: LinkedSQLFavorite? @@ -17,14 +18,24 @@ 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 availableFavoriteTables: [TableInfo] { + let tableByName = tables.reduce(into: [String: TableInfo]()) { result, table in + if result[table.name] == nil { + result[table.name] = table + } + } + return favoriteTables.compactMap { tableByName[$0] } + } - 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 +44,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) { @@ -54,6 +68,9 @@ internal struct FavoritesTabView: View { .onAppear { SQLFolderWatcher.shared.start() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.loadFavorites().sorted() + } .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( connectionId: connectionId, @@ -133,9 +150,22 @@ internal struct FavoritesTabView: View { // MARK: - List - private func favoritesList(_ items: [FavoriteNode]) -> some View { + private func favoritesList(_ items: [FavoriteNode], filteredTables: [TableInfo]) -> some View { List(selection: $sidebarState.selectedFavoriteNodeId) { - nodeRows(items) + if !filteredTables.isEmpty { + Section(String(localized: "Tables")) { + ForEach(filteredTables) { table in + favoriteTableRow(table: table) + } + } + if !items.isEmpty { + Section(String(localized: "Queries")) { + nodeRows(items) + } + } + } else { + nodeRows(items) + } } .listStyle(.sidebar) .scrollContentBackground(.hidden) @@ -152,9 +182,55 @@ internal struct FavoritesTabView: View { } } + @ViewBuilder + private func favoriteTableRow(table: TableInfo) -> some View { + Label { + Text(table.name) + .font(.system(.callout, design: .monospaced)) + } icon: { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + .tag(tableNodeId(table.name)) + .contextMenu { + favoriteTableContextMenu(table) + } + } + + @ViewBuilder + private func favoriteTableContextMenu(_ table: TableInfo) -> some View { + Button(String(localized: "Open Table")) { + coordinator?.openTableTab(table) + } + + Button(String(localized: "View ER Diagram")) { + coordinator?.showERDiagram(tableName: table.name) + } + + Divider() + + Button(role: .destructive) { + FavoriteTablesStorage.shared.removeFavorite(table.name) + } label: { + Text(String(localized: "Remove from Favorites")) + } + } + + private func tableNodeId(_ name: String) -> String { + "table:\(name)" + } + + private func favoriteTable(forNodeId nodeId: String) -> TableInfo? { + guard nodeId.hasPrefix("table:") else { return nil } + let name = String(nodeId.dropFirst("table:".count)) + return availableFavoriteTables.first { $0.name == name } + } + @ViewBuilder private func contextMenuFor(nodeId: String) -> some View { - if let fav = viewModel.favoriteForNodeId(nodeId) { + if let table = favoriteTable(forNodeId: nodeId) { + favoriteTableContextMenu(table) + } else if let fav = viewModel.favoriteForNodeId(nodeId) { favoriteContextMenu(fav) } else if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { linkedFavoriteContextMenu(linked) @@ -166,6 +242,10 @@ internal struct FavoritesTabView: View { } private func handlePrimaryAction(nodeId: String) { + if let table = favoriteTable(forNodeId: nodeId) { + coordinator?.openTableTab(table) + return + } if let fav = viewModel.favoriteForNodeId(nodeId) { coordinator?.insertFavorite(fav) return @@ -175,6 +255,22 @@ internal struct FavoritesTabView: View { } } + private func deleteSelectedNode() { + guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } + if let table = favoriteTable(forNodeId: nodeId) { + FavoriteTablesStorage.shared.removeFavorite(table.name) + return + } + if let fav = viewModel.favoriteForNodeId(nodeId) { + viewModel.deleteFavorite(fav) + return + } + if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { + linkedFileToTrash = linked + showTrashLinkedFileAlert = true + } + } + private func nodeRows(_ items: [FavoriteNode]) -> AnyView { AnyView(ForEach(items) { node in switch node.content { @@ -259,18 +355,6 @@ internal struct FavoritesTabView: View { } } - 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 - } - } - // MARK: - Context Menus @ViewBuilder diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index ccd6ab942..c2c40a650 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -109,6 +109,19 @@ struct SidebarContextMenu: View { } .disabled(!hasSelection) + if let table = clickedTable, selectedTables.count <= 1 { + let isFav = FavoriteTablesStorage.shared.isFavorite(table.name) + let title = isFav ? String(localized: "Remove from Favorites") : String(localized: "Add to Favorites") + Button { + FavoriteTablesStorage.shared.toggle(table.name) + } label: { + Label( + title, + systemImage: isFav ? "star.fill" : "star" + ) + } + } + Button("Export...") { coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) } diff --git a/TablePro/Views/Sidebar/SidebarTableOrdering.swift b/TablePro/Views/Sidebar/SidebarTableOrdering.swift new file mode 100644 index 000000000..5bc2d4dcb --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarTableOrdering.swift @@ -0,0 +1,10 @@ +import TableProPluginKit + +enum SidebarTableOrdering { + static func sortedByFavorite(_ tables: [TableInfo], favoriteTables: Set) -> [TableInfo] { + guard !favoriteTables.isEmpty else { return tables } + let pinned = tables.filter { favoriteTables.contains($0.name) } + let unpinned = tables.filter { !favoriteTables.contains($0.name) } + return pinned + unpinned + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cc072cdbf..b11d38c85 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared + @State private var favoriteTables: Set = FavoriteTablesStorage.shared.loadFavorites() var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -110,6 +111,7 @@ struct SidebarView: View { FavoritesTabView( connectionId: connectionId, windowState: coordinator.windowSidebarState, + tables: tables, coordinator: coordinator ) } else { @@ -267,6 +269,9 @@ struct SidebarView: View { .onExitCommand { windowState.selectedTables.removeAll() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.loadFavorites() + } } // MARK: - Section View @@ -304,11 +309,15 @@ struct SidebarView: View { } } } else { - ForEach(viewModel.filteredTables(of: kind, from: tables)) { table in + ForEach(SidebarTableOrdering.sortedByFavorite( + viewModel.filteredTables(of: kind, from: tables), + favoriteTables: favoriteTables + )) { table in TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + isPendingDelete: pendingDeletes.contains(table.name), + isFavorite: favoriteTables.contains(table.name) ) .tag(table) .contextMenu { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6c23ef681..aa7132a65 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 } @@ -60,6 +62,7 @@ struct TableRow: View { let table: TableInfo let isPendingTruncate: Bool let isPendingDelete: Bool + var isFavorite: Bool = false private var iconColor: Color { TableRowLogic.iconColor(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) @@ -91,11 +94,23 @@ struct TableRow: View { .font(.caption) .sidebarTint(.orange) .offset(x: 4, y: 4) + } else if isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundStyle(.yellow) + .offset(x: 4, y: 4) } } } .padding(.vertical, 4) .accessibilityElement(children: .combine) - .accessibilityLabel(TableRowLogic.accessibilityLabel(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate)) + .accessibilityLabel( + TableRowLogic.accessibilityLabel( + table: table, + isPendingDelete: isPendingDelete, + isPendingTruncate: isPendingTruncate, + isFavorite: isFavorite + ) + ) } } diff --git a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift new file mode 100644 index 000000000..6d5e37e47 --- /dev/null +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -0,0 +1,58 @@ +// +// FavoriteTablesStorageTests.swift +// TableProTests +// + +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() + storage.addFavorite("users") + + let id = FavoriteTablesStorage.syncId(for: "users") + #expect(storage.loadFavorites() == ["users"]) + #expect(metadata.dirtyIds(for: .tableFavorite) == [id]) + } + + @Test("Remove favorite creates sync tombstone") + func removeCreatesTombstone() throws { + let (storage, metadata) = try makeStorage() + storage.addFavorite("users") + storage.removeFavorite("users") + + let id = FavoriteTablesStorage.syncId(for: "users") + #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() + storage.addFavoriteWithoutSync("orders") + storage.removeFavoriteWithoutSync("orders") + + #expect(storage.loadFavorites().isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + #expect(metadata.tombstones(for: .tableFavorite).isEmpty) + } +} diff --git a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift new file mode 100644 index 000000000..4b277992f --- /dev/null +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -0,0 +1,24 @@ +// +// SyncRecordMapperFavoriteTableTests.swift +// TableProTests +// + +import CloudKit +import Foundation +@testable import TablePro +import Testing + +@Suite("SyncRecordMapper favorite tables") +struct SyncRecordMapperFavoriteTableTests { + @Test("Table favorite record round trips table name") + func tableFavoriteRoundTrip() throws { + let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + let record = SyncRecordMapper.toCKRecord(favoriteTableName: "users", in: zoneID) + + let id = FavoriteTablesStorage.syncId(for: "users") + #expect(record.recordType == SyncRecordType.tableFavorite.rawValue) + #expect(record.recordID.recordName == "FavoriteTable_\(id)") + #expect(record["favoriteTableId"] as? String == id) + #expect(try SyncRecordMapper.favoriteTableName(from: record) == "users") + } +} diff --git a/TableProTests/Views/SidebarTableOrderingTests.swift b/TableProTests/Views/SidebarTableOrderingTests.swift new file mode 100644 index 000000000..0be906f1c --- /dev/null +++ b/TableProTests/Views/SidebarTableOrderingTests.swift @@ -0,0 +1,31 @@ +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Sidebar table ordering") +struct SidebarTableOrderingTests { + @Test("Favorite tables are pinned while preserving section order") + func favoritesPinnedWithStableOrder() { + let tables = ["accounts", "orders", "users", "products"].map { + TestFixtures.makeTableInfo(name: $0) + } + + let sorted = SidebarTableOrdering.sortedByFavorite( + tables, + favoriteTables: ["users", "orders"] + ) + + #expect(sorted.map(\.name) == ["orders", "users", "accounts", "products"]) + } + + @Test("Table order is unchanged when there are no favorites") + func unchangedWithoutFavorites() { + let tables = ["accounts", "orders", "users"].map { + TestFixtures.makeTableInfo(name: $0) + } + + let sorted = SidebarTableOrdering.sortedByFavorite(tables, favoriteTables: []) + + #expect(sorted.map(\.name) == ["accounts", "orders", "users"]) + } +} 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..a67c2b05b --- /dev/null +++ b/TableProUITests/TableProLaunchUITests.swift @@ -0,0 +1,19 @@ +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)) + } +} 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..c0bc36013 100644 --- a/docs/features/sql-favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,13 +1,29 @@ --- -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 in the sidebar has two sections: **Tables** for pinned tables and **Queries** for saved SQL. Both appear in the same sidebar tab so you can access them without switching views. + +## Table Favorites + +Right-click any table in the sidebar and choose **Add to Favorites**. The table: + +- Gets a star badge (★) in the sidebar table list +- Moves to the top of its section +- Appears in the **Tables** group at the top of the Favorites tab + +Double-click a table in the Favorites tab to open it. Right-click it to open the table, view its focused ER diagram, or remove it. + +Favorites are stored by table name and sync through iCloud. If a favorited table name doesn't exist in the active connection, it is hidden. + +## 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. From 8189ee13ea11bd8596a717974f69e4b38a18f49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 23:15:22 +0700 Subject: [PATCH 02/28] feat(sidebar): add recent tables, star toggle, create-table button, overflow fix - Recent tables section at top of Tables sidebar (last 10 per connection/database, in-memory, clears on quit). RecentTablesStore pushes on every openTableTab call. - TableRow trailing star button toggles favorite inline; overlay star badge removed. Add/Remove Favorites dropped from SidebarContextMenu (star button replaces it). - Favorites tab now has only Tables and Queries sections (Recent removed). - Plus button next to sidebar search field opens Create Table tab; disabled in safe mode. - Window minimum width now recomputes dynamically when sidebar or inspector collapse/expand, preventing layout overflow on small windows. --- CHANGELOG.md | 11 +++ .../MainSplitViewController.swift | 61 ++++++++++++- .../SidebarContainerViewController.swift | 53 ++++++++++- TablePro/Core/Storage/RecentTablesStore.swift | 75 ++++++++++++++++ TablePro/ViewModels/SidebarViewModel.swift | 1 + .../MainContentCoordinator+Navigation.swift | 5 ++ TablePro/Views/Sidebar/FavoritesTabView.swift | 15 ++-- .../Views/Sidebar/SidebarContextMenu.swift | 39 ++++---- TablePro/Views/Sidebar/SidebarView.swift | 68 +++++++++++++- TablePro/Views/Sidebar/TableRowView.swift | 67 ++++++++------ .../Storage/RecentTablesStoreTests.swift | 90 +++++++++++++++++++ .../Views/SidebarContextMenuLogicTests.swift | 38 ++++++++ docs/features/favorites.mdx | 13 +-- docs/features/table-operations.mdx | 4 + 14 files changed, 476 insertions(+), 64 deletions(-) create mode 100644 TablePro/Core/Storage/RecentTablesStore.swift create mode 100644 TableProTests/Storage/RecentTablesStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dc6a053..6d50a7450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. - Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are pinned to the top of their section, appear in a dedicated Tables group in the Favorites tab, and sync through iCloud. +- A plus button next to the sidebar filter creates a new table without right-clicking. The button is 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. (#1352) - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) - Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425) @@ -22,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clearing a query with the trash button now also clears its results, and a new Clear Results item on the results right-click menu clears results on their own. (#1256) - Inserting SQL from AI Chat opens it in a new query tab instead of appending to the current query. An empty editor is filled in place. (#1257) +### 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 next to the sidebar filter instead. + ### Fixed - Pasting copied rows no longer misplaces values when a cell contains a comma (such as a user agent string); each value stays in its own column, and a real NULL is kept distinct from the literal text "NULL". diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index a3ad26b47..47279e7f0 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -174,12 +174,18 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } else if let session = currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(session.connection.id), - windowState: coordinator.windowSidebarState + windowState: coordinator.windowSidebarState, + coordinator: coordinator ) } inspectorSplitItem.isCollapsed = !inspectorPresented } + override func splitViewDidResizeSubviews(_ notification: Notification) { + super.splitViewDidResizeSubviews(notification) + recomputeWindowMinSize() + } + private func materializeInspectorIfNeeded() { guard !hasMaterializedInspector, let inspectorHosting else { return } hasMaterializedInspector = true @@ -204,11 +210,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState + windowState: coordinator.windowSidebarState, + coordinator: coordinator ) } installObservers() + recomputeWindowMinSize() } override func viewDidDisappear() { @@ -321,7 +329,8 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState + windowState: coordinator.windowSidebarState, + coordinator: coordinator ) } detailHosting.rootView = AnyView(buildDetailView()) @@ -469,11 +478,13 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi materializeInspectorIfNeeded() inspectorSplitItem?.animator().isCollapsed = false UserDefaults.standard.set(true, forKey: Self.inspectorPresentedKey) + recomputeWindowMinSize() } func hideInspector() { inspectorSplitItem?.animator().isCollapsed = true UserDefaults.standard.set(false, forKey: Self.inspectorPresentedKey) + recomputeWindowMinSize() } @objc override func toggleInspector(_ sender: Any?) { @@ -500,6 +511,50 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } } + // MARK: - Dynamic Window Minimum Size + + private static let baseWindowMinWidth: CGFloat = 720 + private static let baseWindowMinHeight: CGFloat = 480 + + private func recomputeWindowMinSize() { + guard let window = view.window else { return } + let sidebarVisible = !(sidebarSplitItem?.isCollapsed ?? true) + let inspectorVisible = !(inspectorSplitItem?.isCollapsed ?? true) + + let detailMin: CGFloat = detailSplitItem?.minimumThickness ?? 400 + let sidebarMin: CGFloat = sidebarSplitItem?.minimumThickness ?? 280 + let inspectorMin: CGFloat = inspectorSplitItem?.minimumThickness ?? 270 + let dividerThickness = splitView.dividerThickness + + var width: CGFloat = detailMin + if sidebarVisible { + width += sidebarMin + dividerThickness + } + if inspectorVisible { + width += inspectorMin + dividerThickness + } + + let resolvedWidth = max(Self.baseWindowMinWidth, width) + let newMinSize = NSSize(width: resolvedWidth, height: Self.baseWindowMinHeight) + + guard window.minSize != newMinSize else { return } + window.minSize = newMinSize + + var frame = window.frame + var resized = false + if frame.size.width < resolvedWidth { + frame.size.width = resolvedWidth + resized = true + } + if frame.size.height < Self.baseWindowMinHeight { + frame.size.height = Self.baseWindowMinHeight + resized = true + } + if resized { + window.setFrame(frame, display: true, animate: window.isVisible) + } + } + // MARK: - Constants private static let inspectorPresentedKey = "com.TablePro.rightPanel.isPresented" diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 9b43e6fee..d26baa7c4 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -9,9 +9,11 @@ import SwiftUI @MainActor internal final class SidebarContainerViewController: NSViewController { private let searchField = NSSearchField() + private let createTableButton = NSButton() private var hostingController: NSHostingController private var sidebarState: SharedSidebarState? private var windowState: WindowSidebarState? + private weak var coordinator: MainContentCoordinator? private var observationGeneration = 0 var rootView: AnyView { @@ -40,6 +42,9 @@ internal final class SidebarContainerViewController: NSViewController { searchField.setAccessibilityIdentifier("sidebar-filter") view.addSubview(searchField) + configureCreateTableButton() + view.addSubview(createTableButton) + addChild(hostingController) let hostingView = hostingController.view hostingView.translatesAutoresizingMaskIntoConstraints = false @@ -48,7 +53,12 @@ internal final class SidebarContainerViewController: NSViewController { NSLayoutConstraint.activate([ searchField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), - searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + searchField.trailingAnchor.constraint(equalTo: createTableButton.leadingAnchor, constant: -6), + + createTableButton.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), + createTableButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + createTableButton.widthAnchor.constraint(equalToConstant: 22), + createTableButton.heightAnchor.constraint(equalToConstant: 22), hostingView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 5), hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -57,16 +67,44 @@ internal final class SidebarContainerViewController: NSViewController { ]) } - func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) { + private func configureCreateTableButton() { + createTableButton.translatesAutoresizingMaskIntoConstraints = false + createTableButton.bezelStyle = .accessoryBarAction + createTableButton.isBordered = false + createTableButton.image = NSImage(systemSymbolName: "plus", accessibilityDescription: nil) + createTableButton.imageScaling = .scaleProportionallyDown + createTableButton.contentTintColor = .secondaryLabelColor + createTableButton.toolTip = String(localized: "Create New Table") + createTableButton.setAccessibilityLabel(String(localized: "Create New Table")) + createTableButton.setAccessibilityIdentifier("sidebar-create-table") + createTableButton.target = self + createTableButton.action = #selector(handleCreateTableClicked(_:)) + createTableButton.isEnabled = false + createTableButton.isHidden = true + } + + @objc private func handleCreateTableClicked(_ sender: Any?) { + coordinator?.createNewTable() + } + + func updateSidebarState( + _ state: SharedSidebarState?, + windowState: WindowSidebarState?, + coordinator: MainContentCoordinator? = nil + ) { observationGeneration += 1 self.sidebarState = state self.windowState = windowState + self.coordinator = coordinator guard let state, let windowState else { searchField.isHidden = true + createTableButton.isHidden = true + createTableButton.isEnabled = false return } searchField.isHidden = false syncFromState(state, windowState: windowState) + syncCreateTableEnabled() startObserving(state, windowState: windowState, generation: observationGeneration) } @@ -79,6 +117,7 @@ internal final class SidebarContainerViewController: NSViewController { _ = state.selectedSidebarTab _ = windowState.searchText _ = windowState.favoritesSearchText + _ = coordinator?.safeModeLevel } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, @@ -86,6 +125,7 @@ internal final class SidebarContainerViewController: NSViewController { let sidebarState = self.sidebarState, let windowState = self.windowState else { return } self.syncFromState(sidebarState, windowState: windowState) + self.syncCreateTableEnabled() self.startObserving(sidebarState, windowState: windowState, generation: generation) } } @@ -108,6 +148,15 @@ internal final class SidebarContainerViewController: NSViewController { } searchField.placeholderString = placeholder } + + private func syncCreateTableEnabled() { + createTableButton.isHidden = false + guard let coordinator else { + createTableButton.isEnabled = false + return + } + createTableButton.isEnabled = !coordinator.safeModeLevel.blocksAllWrites + } } extension SidebarContainerViewController: NSSearchFieldDelegate { diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..0975ab3ad --- /dev/null +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -0,0 +1,75 @@ +// +// RecentTablesStore.swift +// TablePro +// + +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 + let lastAccessedAt: Date + + 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, + lastAccessedAt: Date() + ), + 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/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/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 813158eff..f5aae554c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,6 +21,11 @@ extension MainContentCoordinator { redirectToSibling: Bool = false, forceNonPreview: Bool = false ) { + RecentTablesStore.shared.push( + connectionID: connection.id, + database: activeDatabaseName, + table: table + ) openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index dd97541e5..88d8dca9e 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -150,7 +150,10 @@ internal struct FavoritesTabView: View { // MARK: - List - private func favoritesList(_ items: [FavoriteNode], filteredTables: [TableInfo]) -> some View { + private func favoritesList( + _ items: [FavoriteNode], + filteredTables: [TableInfo] + ) -> some View { List(selection: $sidebarState.selectedFavoriteNodeId) { if !filteredTables.isEmpty { Section(String(localized: "Tables")) { @@ -158,13 +161,11 @@ internal struct FavoritesTabView: View { favoriteTableRow(table: table) } } - if !items.isEmpty { - Section(String(localized: "Queries")) { - nodeRows(items) - } + } + if !items.isEmpty { + Section(String(localized: "Queries")) { + nodeRows(items) } - } else { - nodeRows(items) } } .listStyle(.sidebar) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index c2c40a650..1e754e498 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -45,6 +45,17 @@ enum SidebarContextMenuLogic { case .table, .none: return String(localized: "Delete") } } + + /// True when the Maintenance group has at least one runnable child. + /// Disables the parent menu when every child action is unreachable. + 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 @@ -72,11 +83,6 @@ struct SidebarContextMenu: View { } var body: some View { - Button("Create New Table...") { - coordinator?.createNewTable() - } - .disabled(isReadOnly) - Button("Create New View...") { coordinator?.createView() } @@ -109,19 +115,6 @@ struct SidebarContextMenu: View { } .disabled(!hasSelection) - if let table = clickedTable, selectedTables.count <= 1 { - let isFav = FavoriteTablesStorage.shared.isFavorite(table.name) - let title = isFav ? String(localized: "Remove from Favorites") : String(localized: "Add to Favorites") - Button { - FavoriteTablesStorage.shared.toggle(table.name) - } label: { - Label( - title, - systemImage: isFav ? "star.fill" : "star" - ) - } - } - Button("Export...") { coordinator?.openExportDialog(preselectedTableNames: Set(effectiveTableNames)) } @@ -139,9 +132,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) @@ -149,7 +147,6 @@ struct SidebarContextMenu: View { } } } - .disabled(isReadOnly) } Divider() diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index b11d38c85..18fe3a335 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -12,6 +12,7 @@ struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared @State private var favoriteTables: Set = FavoriteTablesStorage.shared.loadFavorites() + @State private var recentTables: [RecentTablesStore.Entry] = [] var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -234,8 +235,66 @@ 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 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 reloadRecentTables() { + guard let database = coordinator?.activeDatabaseName else { + recentTables = [] + return + } + recentTables = RecentTablesStore.shared.entries( + connectionID: connectionId, + database: database + ) + } + + @ViewBuilder + private var recentSection: some View { + let recents = filteredRecents + if !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: favoriteTables.contains(info.name), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(info.name) } + ) + .tag(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) } @@ -272,6 +331,12 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = FavoriteTablesStorage.shared.loadFavorites() } + .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in + reloadRecentTables() + } + .onAppear { + reloadRecentTables() + } } // MARK: - Section View @@ -317,7 +382,8 @@ struct SidebarView: View { table: table, isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), - isFavorite: favoriteTables.contains(table.name) + isFavorite: favoriteTables.contains(table.name), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(table.name) } ) .tag(table) .contextMenu { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index aa7132a65..4607d183b 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -63,6 +63,7 @@ struct TableRow: View { let isPendingTruncate: Bool let isPendingDelete: Bool var isFavorite: Bool = false + var onToggleFavorite: (() -> Void)? private var iconColor: Color { TableRowLogic.iconColor(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) @@ -73,34 +74,50 @@ 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) + 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) - } else if isFavorite { - Image(systemName: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - .offset(x: 4, y: 4) + 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 { + Button(action: onToggleFavorite) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(isFavorite ? Color.yellow : Color.secondary.opacity(0.55)) + .contentShape(Rectangle()) + .frame(width: 16, height: 16) + } + .buttonStyle(.plain) + .help(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) + .accessibilityLabel(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) + } } .padding(.vertical, 4) .accessibilityElement(children: .combine) diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift new file mode 100644 index 000000000..6e98ea7e0 --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,90 @@ +// +// RecentTablesStoreTests.swift +// TableProTests +// + +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"]) + } +} 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/docs/features/favorites.mdx b/docs/features/favorites.mdx index c0bc36013..9f15e67a8 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -5,20 +5,23 @@ description: Mark tables as favorites and save frequently used queries with opti # Favorites -The Favorites tab in the sidebar has two sections: **Tables** for pinned tables and **Queries** for saved SQL. Both appear in the same sidebar tab so you can access them without switching views. +The Tables sidebar shows a **Recent** section at the top with the last 10 tables you opened in the current connection and database. The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. ## Table Favorites -Right-click any table in the sidebar and choose **Add to Favorites**. The table: +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: -- Gets a star badge (★) in the sidebar table list -- Moves to the top of its section -- Appears in the **Tables** group at the top of the Favorites tab +- 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, view its focused ER diagram, or remove it. Favorites are stored by table name and sync through iCloud. If a favorited table name doesn't exist in the active connection, it is hidden. +## Recent Tables + +Each table you open is added to the **Recent** section at the top of the Tables sidebar. The list 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. diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index aced3f9a7..daf46a362 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 next to the sidebar filter to open a Create Table tab. The button is disabled while safe mode blocks writes. + ## Drop Table Permanently deletes a table and all its data. From 91e4126a0e43f25be3c528b231babc96aacff852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Tue, 26 May 2026 22:29:50 +0700 Subject: [PATCH 03/28] feat(sidebar): handle tableFavorite in conflict resolution, fix showERDiagram call --- TablePro/Resources/Localizable.xcstrings | 30 +++++++++++++++++++ .../Components/ConflictResolutionView.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 2 +- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d9f25edc0..99a3bda2e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4737,6 +4737,10 @@ } } }, + "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" : { "tr" : { @@ -13351,7 +13355,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" : { @@ -21361,6 +21370,10 @@ } } }, + "favorite" : { + "comment" : "A label indicating that a table is marked as a favorite.", + "isCommentAutoGenerated" : true + }, "Favorites" : { "localizations" : { "tr" : { @@ -33313,6 +33326,10 @@ } } }, + "Open Table" : { + "comment" : "A context menu option to open a table in the main view.", + "isCommentAutoGenerated" : true + }, "Open Table Tab" : { }, @@ -36584,6 +36601,10 @@ } } }, + "Queries" : { + "comment" : "A section header for the list of queries in the favorites tab.", + "isCommentAutoGenerated" : true + }, "Query" : { "localizations" : { "tr" : { @@ -38456,6 +38477,10 @@ } } }, + "Remove from Favorites" : { + "comment" : "A button label that deletes a table from the user's favorites.", + "isCommentAutoGenerated" : true + }, "Remove from Group" : { "localizations" : { "tr" : { @@ -46310,6 +46335,7 @@ } }, "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46331,6 +46357,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/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/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 88d8dca9e..ba01b95af 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -205,7 +205,7 @@ internal struct FavoritesTabView: View { } Button(String(localized: "View ER Diagram")) { - coordinator?.showERDiagram(tableName: table.name) + coordinator?.showERDiagram() } Divider() From 392c650419b8c57b64c1c1710450da1a24becc83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Tue, 26 May 2026 22:59:14 +0700 Subject: [PATCH 04/28] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1dd4bc8e4..9c6ddea64 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,7 @@ Thumbs.db *.p12 *.mobileprovision Secrets.xcconfig +Local.xcconfig # Debug *.log From df7fbef57f86b14cd2c5fb0b051f742a0373e017 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Tue, 26 May 2026 23:31:09 +0700 Subject: [PATCH 05/28] refactor(sidebar): remove ER diagram context menu item, drop SidebarTableOrdering --- .../Views/Sidebar/SidebarContextMenu.swift | 4 --- .../Views/Sidebar/SidebarTableOrdering.swift | 10 ------ TablePro/Views/Sidebar/SidebarView.swift | 5 +-- .../Views/SidebarTableOrderingTests.swift | 31 ------------------- 4 files changed, 1 insertion(+), 49 deletions(-) delete mode 100644 TablePro/Views/Sidebar/SidebarTableOrdering.swift delete mode 100644 TableProTests/Views/SidebarTableOrderingTests.swift diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 1e754e498..f2e8e3b5f 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -106,10 +106,6 @@ struct SidebarContextMenu: View { } .disabled(clickedTable == nil) - Button(String(localized: "View ER Diagram")) { - coordinator?.showERDiagram() - } - Button("Copy Name") { ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) } diff --git a/TablePro/Views/Sidebar/SidebarTableOrdering.swift b/TablePro/Views/Sidebar/SidebarTableOrdering.swift deleted file mode 100644 index 5bc2d4dcb..000000000 --- a/TablePro/Views/Sidebar/SidebarTableOrdering.swift +++ /dev/null @@ -1,10 +0,0 @@ -import TableProPluginKit - -enum SidebarTableOrdering { - static func sortedByFavorite(_ tables: [TableInfo], favoriteTables: Set) -> [TableInfo] { - guard !favoriteTables.isEmpty else { return tables } - let pinned = tables.filter { favoriteTables.contains($0.name) } - let unpinned = tables.filter { !favoriteTables.contains($0.name) } - return pinned + unpinned - } -} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 18fe3a335..576d77355 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -374,10 +374,7 @@ struct SidebarView: View { } } } else { - ForEach(SidebarTableOrdering.sortedByFavorite( - viewModel.filteredTables(of: kind, from: tables), - favoriteTables: favoriteTables - )) { table in + ForEach(viewModel.filteredTables(of: kind, from: tables)) { table in TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), diff --git a/TableProTests/Views/SidebarTableOrderingTests.swift b/TableProTests/Views/SidebarTableOrderingTests.swift deleted file mode 100644 index 0be906f1c..000000000 --- a/TableProTests/Views/SidebarTableOrderingTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -@testable import TablePro -import TableProPluginKit -import Testing - -@Suite("Sidebar table ordering") -struct SidebarTableOrderingTests { - @Test("Favorite tables are pinned while preserving section order") - func favoritesPinnedWithStableOrder() { - let tables = ["accounts", "orders", "users", "products"].map { - TestFixtures.makeTableInfo(name: $0) - } - - let sorted = SidebarTableOrdering.sortedByFavorite( - tables, - favoriteTables: ["users", "orders"] - ) - - #expect(sorted.map(\.name) == ["orders", "users", "accounts", "products"]) - } - - @Test("Table order is unchanged when there are no favorites") - func unchangedWithoutFavorites() { - let tables = ["accounts", "orders", "users"].map { - TestFixtures.makeTableInfo(name: $0) - } - - let sorted = SidebarTableOrdering.sortedByFavorite(tables, favoriteTables: []) - - #expect(sorted.map(\.name) == ["accounts", "orders", "users"]) - } -} From c5d72f64c48d8267ea74fd612215cc0d92550647 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 07:06:23 +0000 Subject: [PATCH 06/28] fix(sidebar): address PR review blockers and design concerns - feat(sync): add syncTableFavorites toggle to SyncSettings and SyncSection - refactor(sidebar): scope FavoriteTablesStorage by (connectionId, schema, name) instead of global table name - fix(sync): gate tableFavorite push/apply/clear behind syncTableFavorites setting - fix(storage): add NSLock to FavoriteTablesStorage for thread safety - refactor(storage): change RecentTablesStore.Key.database to String? with nilIfEmpty convention - fix(changelog): add View ER Diagram removal entry - test: update FavoriteTablesStorageTests for connection-scoped favorites - test: update SyncRecordMapperFavoriteTableTests with connectionId and schema - test: add nil-database test to RecentTablesStoreTests - test(ui): add window minimum size assertion --- CHANGELOG.md | 1 + CLAUDE.md | 5 +- .../Core/Storage/FavoriteTablesStorage.swift | 126 +++++++++++------- TablePro/Core/Storage/RecentTablesStore.swift | 8 +- TablePro/Core/Sync/SyncCoordinator.swift | 32 +++-- TablePro/Core/Sync/SyncRecordMapper.swift | 19 ++- TablePro/Models/Settings/SyncSettings.swift | 9 +- .../MainContentCoordinator+Navigation.swift | 2 +- .../Views/Settings/Sections/SyncSection.swift | 1 + TablePro/Views/Sidebar/FavoritesTabView.swift | 16 +-- TablePro/Views/Sidebar/SidebarView.swift | 18 ++- .../Storage/FavoriteTablesStorageTests.swift | 65 +++++++-- .../SyncRecordMapperFavoriteTableTests.swift | 42 ++++-- .../Storage/RecentTablesStoreTests.swift | 10 ++ TableProUITests/TableProLaunchUITests.swift | 13 ++ 15 files changed, 254 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d50a7450..b7e024f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - "Create New Table…" from the sidebar right-click menu. Use the plus button next to the sidebar filter instead. +- "View ER Diagram" from the sidebar right-click menu. Access it from the Favorites tab context menu instead. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 48ba85ccb..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ These govern every decision — code, architecture, tooling, and process: 4. **Clean code** — self-explanatory naming, early returns over nested conditionals, small focused functions. No comments in the codebase — code must be self-documenting through clear naming and structure. 5. **Root cause fixes** — don't patch symptoms. Diagnose the underlying issue, add logging to debug if needed, then fix the actual cause. 6. **No hacky solutions** — no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix. -7. **Testability** — every testable code change needs unit/function tests, and UI/user-flow changes need UI automation when deterministic. When tests fail, fix the source code — never adjust tests to match incorrect output. +7. **Testability** — if a feature is testable, write tests. When tests fail, fix the source code — never adjust tests to match incorrect output. 8. **Maintainability** — follow existing patterns but offer refactors when they improve quality. Extract into extensions when approaching size limits. Group by domain logic. 9. **Scalability** — design for the plugin system's open-ended nature. `DatabaseType` is a struct, not an enum. All switches need `default:`. @@ -52,7 +52,6 @@ swiftformat . # Format code xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests # DMG scripts/create-dmg.sh @@ -224,7 +223,7 @@ These are **non-negotiable** — never skip them: - Settings changes → `docs/customization/settings.mdx` - Database driver changes → `docs/databases/*.mdx` -4. **Tests**: Every code change must include or update unit/function tests for testable behavior. UI and user-flow changes must also include or update `TableProUITests` UI automation when the flow can run deterministically; if not, state the blocker in the handoff. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior. +4. **Tests**: Write tests for testable features. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior. 5. **Lint after changes**: Run `swiftlint lint --strict` to verify compliance. diff --git a/TablePro/Core/Storage/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift index 4b2e3bdf9..37281b6f2 100644 --- a/TablePro/Core/Storage/FavoriteTablesStorage.swift +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -1,7 +1,3 @@ -// -// FavoriteTablesStorage.swift -// TablePro -// import Foundation import os @@ -14,77 +10,117 @@ final class FavoriteTablesStorage { static let shared = FavoriteTablesStorage() private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteTablesStorage") + struct FavoriteEntry: Codable, Hashable { + let connectionId: UUID + let schema: String? + let name: String + } + private let defaults: UserDefaults private let syncTracker: SyncChangeTracker private let key = "com.TablePro.favoriteTables" - private var cache: Set? + private var cache: Set? + private let lock = NSLock() init(userDefaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { self.defaults = userDefaults self.syncTracker = syncTracker } - 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 + 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) -> Bool { - loadFavorites().contains(name) + func isFavorite(name: String, schema: String?, connectionId: UUID) -> Bool { + lock.lock() + defer { lock.unlock() } + return _loadFavorites().contains(FavoriteEntry(connectionId: connectionId, schema: schema, name: name)) } - func toggle(_ name: String) { - if isFavorite(name) { - removeFavorite(name) + func toggle(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + let isPresent = favorites.contains(entry) + lock.unlock() + + if isPresent { + removeFavorite(name: name, schema: schema, connectionId: connectionId) } else { - addFavorite(name) + addFavorite(name: name, schema: schema, connectionId: connectionId) } } - func addFavorite(_ name: String) { - var favorites = loadFavorites() - guard favorites.insert(name).inserted else { return } - persist(favorites) - syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: name)) + func addFavorite(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + guard favorites.insert(entry).inserted else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: entry)) } - func addFavoriteWithoutSync(_ name: String) { - var favorites = loadFavorites() - guard favorites.insert(name).inserted else { return } - persist(favorites) + func addFavoriteWithoutSync(_ entry: FavoriteEntry) { + lock.lock() + var favorites = _loadFavorites() + guard favorites.insert(entry).inserted else { lock.unlock(); return } + _persist(favorites) + lock.unlock() } - func removeFavorite(_ name: String) { - var favorites = loadFavorites() - guard favorites.remove(name) != nil else { return } - persist(favorites) - syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: name)) + func removeFavorite(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + guard favorites.remove(entry) != nil else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) } - func removeFavoriteWithoutSync(_ name: String) { - var favorites = loadFavorites() - guard favorites.remove(name) != nil else { return } - persist(favorites) + func removeFavoriteWithoutSync(_ entry: FavoriteEntry) { + lock.lock() + var favorites = _loadFavorites() + guard favorites.remove(entry) != nil else { lock.unlock(); return } + _persist(favorites) + lock.unlock() } func removeFavoriteWithoutSync(id: String) { - var favorites = loadFavorites() - guard let name = favorites.first(where: { Self.syncId(for: $0) == id }) else { return } - favorites.remove(name) - persist(favorites) + lock.lock() + var favorites = _loadFavorites() + guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { lock.unlock(); return } + favorites.remove(entry) + _persist(favorites) + lock.unlock() + } + + static func syncId(for entry: FavoriteEntry) -> String { + let raw = entry.connectionId.uuidString + "|" + (entry.schema ?? "") + "|" + entry.name + return raw.sha256 } - static func syncId(for name: String) -> String { - name.sha256 + 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) { + private func _persist(_ favorites: Set) { cache = favorites guard let data = try? JSONEncoder().encode(favorites) else { Self.logger.error("Failed to encode favorite tables") diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index 0975ab3ad..431b1727c 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -15,7 +15,7 @@ final class RecentTablesStore { struct Key: Hashable { let connectionID: UUID - let database: String + let database: String? } struct Entry: Hashable, Identifiable { @@ -32,7 +32,7 @@ final class RecentTablesStore { init() {} - func push(connectionID: UUID, database: String, table: TableInfo) { + 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) @@ -53,11 +53,11 @@ final class RecentTablesStore { NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) } - func entries(connectionID: UUID, database: String) -> [Entry] { + func entries(connectionID: UUID, database: String?) -> [Entry] { entriesByKey[Key(connectionID: connectionID, database: database)] ?? [] } - func clear(connectionID: UUID, database: String) { + func clear(connectionID: UUID, database: String?) { entriesByKey.removeValue(forKey: Key(connectionID: connectionID, database: database)) NotificationCenter.default.post(name: .recentTablesDidChange, object: nil) } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index f0900caa1..280ef16bd 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -168,8 +168,8 @@ final class SyncCoordinator { } let favoriteTables = services.favoriteTablesStorage.loadFavorites() - for tableName in favoriteTables { - changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: tableName)) + for entry in favoriteTables { + changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: entry)) } // Mark all settings categories as dirty @@ -304,7 +304,9 @@ final class SyncCoordinator { } } - collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + 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)) @@ -327,7 +329,9 @@ final class SyncCoordinator { if settings.syncSettings { changeTracker.clearAllDirty(.settings) } - changeTracker.clearAllDirty(.tableFavorite) + if settings.syncTableFavorites { + changeTracker.clearAllDirty(.tableFavorite) + } // Clear tombstones only for types that were actually pushed if settings.syncConnections { @@ -353,8 +357,10 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) } } - for tombstone in metadataStorage.tombstones(for: .tableFavorite) { - metadataStorage.removeTombstone(type: .tableFavorite, 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") @@ -442,7 +448,7 @@ final class SyncCoordinator { applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) - case SyncRecordType.tableFavorite.rawValue: + case SyncRecordType.tableFavorite.rawValue where settings.syncTableFavorites: applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) default: break @@ -622,9 +628,9 @@ final class SyncCoordinator { @discardableResult private func applyRemoteTableFavorite(_ record: CKRecord, tombstoneIds: Set) -> Bool { - let name: String + let entry: FavoriteTablesStorage.FavoriteEntry do { - name = try SyncRecordMapper.favoriteTableName(from: record) + entry = try SyncRecordMapper.favoriteEntry(from: record) } catch { let recordName = record.recordID.recordName let message = error.localizedDescription @@ -633,9 +639,9 @@ final class SyncCoordinator { ) return false } - if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: name)) { return false } + if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: entry)) { return false } let before = services.favoriteTablesStorage.loadFavorites() - services.favoriteTablesStorage.addFavoriteWithoutSync(name) + services.favoriteTablesStorage.addFavoriteWithoutSync(entry) return before != services.favoriteTablesStorage.loadFavorites() } @@ -887,8 +893,8 @@ final class SyncCoordinator { let dirtyIds = changeTracker.dirtyRecords(for: .tableFavorite) if !dirtyIds.isEmpty { let favorites = services.favoriteTablesStorage.loadFavorites() - for name in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: name)) { - records.append(SyncRecordMapper.toCKRecord(favoriteTableName: name, in: zoneID)) + for entry in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: entry)) { + records.append(SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID)) } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index db00d3d06..83e375dbe 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -327,24 +327,33 @@ struct SyncRecordMapper { // MARK: - Table Favorite - static func toCKRecord(favoriteTableName name: String, in zone: CKRecordZone.ID) -> CKRecord { - let favoriteId = FavoriteTablesStorage.syncId(for: name) + 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["favoriteTableId"] = favoriteId as CKRecordValue - record["name"] = name as CKRecordValue + record["connectionId"] = entry.connectionId.uuidString as CKRecordValue + record["name"] = entry.name 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 favoriteTableName(from record: CKRecord) throws -> String { + static func favoriteEntry(from record: CKRecord) throws -> FavoriteTablesStorage.FavoriteEntry { guard let name = record["name"] as? String, !name.isEmpty else { throw SyncDecodeError.missingRequiredField("name") } - return name + guard let connectionIdString = record["connectionId"] as? String, + let connectionId = UUID(uuidString: connectionIdString) else { + throw SyncDecodeError.missingRequiredField("connectionId") + } + let schema = record["schema"] as? String + return FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: schema, name: name) } // MARK: - SSH Profile 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/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f5aae554c..4cd8b9d74 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -23,7 +23,7 @@ extension MainContentCoordinator { ) { RecentTablesStore.shared.push( connectionID: connection.id, - database: activeDatabaseName, + database: activeDatabaseName.nilIfEmpty, table: table ) openTableTab( diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index ad360b469..79201d7e0 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -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 ba01b95af..75f9ac712 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -7,7 +7,7 @@ import SwiftUI internal struct FavoritesTabView: View { @State private var viewModel: FavoritesSidebarViewModel - @State private var favoriteTables: [String] = FavoriteTablesStorage.shared.loadFavorites().sorted() + @State private var favoriteTables: [FavoriteTablesStorage.FavoriteEntry] = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @State private var linkedFileToTrash: LinkedSQLFavorite? @@ -24,12 +24,9 @@ internal struct FavoritesTabView: View { private var searchText: String { windowState.favoritesSearchText } private var availableFavoriteTables: [TableInfo] { - let tableByName = tables.reduce(into: [String: TableInfo]()) { result, table in - if result[table.name] == nil { - result[table.name] = table - } + favoriteTables.compactMap { entry in + tables.first { $0.name == entry.name && $0.schema == entry.schema } } - return favoriteTables.compactMap { tableByName[$0] } } init(connectionId: UUID, windowState: WindowSidebarState, tables: [TableInfo], coordinator: MainContentCoordinator?) { @@ -67,9 +64,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.loadFavorites().sorted() + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } } .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( @@ -211,7 +209,7 @@ internal struct FavoritesTabView: View { Divider() Button(role: .destructive) { - FavoriteTablesStorage.shared.removeFavorite(table.name) + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) } label: { Text(String(localized: "Remove from Favorites")) } @@ -259,7 +257,7 @@ internal struct FavoritesTabView: View { private func deleteSelectedNode() { guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } if let table = favoriteTable(forNodeId: nodeId) { - FavoriteTablesStorage.shared.removeFavorite(table.name) + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) return } if let fav = viewModel.favoriteForNodeId(nodeId) { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 576d77355..033135a4c 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,7 +11,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared - @State private var favoriteTables: Set = FavoriteTablesStorage.shared.loadFavorites() + @State private var favoriteTables: Set = [] @State private var recentTables: [RecentTablesStore.Entry] = [] var sidebarState: SharedSidebarState @@ -249,10 +249,7 @@ struct SidebarView: View { } private func reloadRecentTables() { - guard let database = coordinator?.activeDatabaseName else { - recentTables = [] - return - } + let database = coordinator?.activeDatabaseName.nilIfEmpty recentTables = RecentTablesStore.shared.entries( connectionID: connectionId, database: database @@ -270,8 +267,8 @@ struct SidebarView: View { table: info, isPendingTruncate: pendingTruncates.contains(info.name), isPendingDelete: pendingDeletes.contains(info.name), - isFavorite: favoriteTables.contains(info.name), - onToggleFavorite: { FavoriteTablesStorage.shared.toggle(info.name) } + isFavorite: favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: info.schema, name: info.name)), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: info.name, schema: info.schema, connectionId: connectionId) } ) .tag(info) .contextMenu { @@ -329,12 +326,13 @@ struct SidebarView: View { windowState.selectedTables.removeAll() } .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in - favoriteTables = FavoriteTablesStorage.shared.loadFavorites() + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in reloadRecentTables() } .onAppear { + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) reloadRecentTables() } } @@ -379,8 +377,8 @@ struct SidebarView: View { table: table, isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), - isFavorite: favoriteTables.contains(table.name), - onToggleFavorite: { FavoriteTablesStorage.shared.toggle(table.name) } + isFavorite: favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: table.schema, name: table.name)), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: table.name, schema: table.schema, connectionId: connectionId) } ) .tag(table) .contextMenu { diff --git a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift index 6d5e37e47..636609e33 100644 --- a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -1,7 +1,3 @@ -// -// FavoriteTablesStorageTests.swift -// TableProTests -// import Foundation @testable import TablePro @@ -26,20 +22,24 @@ struct FavoriteTablesStorageTests { @Test("Add favorite marks stable sync ID dirty") func addMarksDirty() throws { let (storage, metadata) = try makeStorage() - storage.addFavorite("users") + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, connectionId: connId) - let id = FavoriteTablesStorage.syncId(for: "users") - #expect(storage.loadFavorites() == ["users"]) + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, 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() - storage.addFavorite("users") - storage.removeFavorite("users") + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, connectionId: connId) + storage.removeFavorite(name: "users", schema: nil, connectionId: connId) - let id = FavoriteTablesStorage.syncId(for: "users") + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, 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 }) @@ -48,11 +48,52 @@ struct FavoriteTablesStorageTests { @Test("Remote apply helpers do not track local sync changes") func withoutSyncDoesNotTrackChanges() throws { let (storage, metadata) = try makeStorage() - storage.addFavoriteWithoutSync("orders") - storage.removeFavoriteWithoutSync("orders") + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, 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, connectionId: connA) + storage.addFavorite(name: "users", schema: 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", connectionId: connId) + storage.addFavorite(name: "users", schema: "app", connectionId: connId) + storage.addFavorite(name: "users", schema: nil, connectionId: connId) + + #expect(storage.favorites(for: connId).count == 3) + } + + @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, connectionId: connId) + storage.toggle(name: "orders", schema: 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 index 4b277992f..301a7c179 100644 --- a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -1,7 +1,3 @@ -// -// SyncRecordMapperFavoriteTableTests.swift -// TableProTests -// import CloudKit import Foundation @@ -10,15 +6,43 @@ import Testing @Suite("SyncRecordMapper favorite tables") struct SyncRecordMapperFavoriteTableTests { - @Test("Table favorite record round trips table name") + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + @Test("Table favorite record round trips all fields") func tableFavoriteRoundTrip() throws { - let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) - let record = SyncRecordMapper.toCKRecord(favoriteTableName: "users", in: zoneID) + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: "public", name: "users") + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) - let id = FavoriteTablesStorage.syncId(for: "users") + let id = FavoriteTablesStorage.syncId(for: entry) #expect(record.recordType == SyncRecordType.tableFavorite.rawValue) #expect(record.recordID.recordName == "FavoriteTable_\(id)") #expect(record["favoriteTableId"] as? String == id) - #expect(try SyncRecordMapper.favoriteTableName(from: record) == "users") + #expect(record["name"] as? String == "users") + #expect(record["connectionId"] as? String == connId.uuidString) + #expect(record["schema"] as? String == "public") + + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @Test("Table favorite without schema round trips correctly") + func tableFavoriteNoSchemaRoundTrip() throws { + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "orders") + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) + + #expect(record["schema"] == nil) + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @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, schema: nil, name: "users") + let entryB = FavoriteTablesStorage.FavoriteEntry(connectionId: connB, schema: nil, name: "users") + #expect(FavoriteTablesStorage.syncId(for: entryA) != FavoriteTablesStorage.syncId(for: entryB)) } } diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift index 6e98ea7e0..949647f87 100644 --- a/TableProTests/Storage/RecentTablesStoreTests.swift +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -87,4 +87,14 @@ struct RecentTablesStoreTests { #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/TableProUITests/TableProLaunchUITests.swift b/TableProUITests/TableProLaunchUITests.swift index a67c2b05b..adc61e636 100644 --- a/TableProUITests/TableProLaunchUITests.swift +++ b/TableProUITests/TableProLaunchUITests.swift @@ -16,4 +16,17 @@ final class TableProLaunchUITests: XCTestCase { XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) } + + func testMainWindowRespectsMinimumSize() 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, 950, "Window width must be at least the base minimum") + XCTAssertGreaterThanOrEqual(frame.height, 480, "Window height must be at least the base minimum") + } } From c3ab76c807522bfa6bc4d6896a4fa04d460b4b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 29 May 2026 08:45:08 +0700 Subject: [PATCH 07/28] =?UTF-8?q?fix(sidebar):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20node-id=20schema,=20list=20selection,=20lock=20isol?= =?UTF-8?q?ation,=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - CHANGELOG.md | 5 +- .../MainSplitViewController.swift | 2 +- TablePro/Core/Storage/ConnectionStorage.swift | 3 + .../Core/Storage/FavoriteTablesStorage.swift | 131 +++++++++++++----- TablePro/Core/Storage/RecentTablesStore.swift | 5 - TablePro/Core/Sync/SyncCoordinator.swift | 4 +- TablePro/Core/Sync/SyncRecordMapper.swift | 1 - TablePro/Resources/Localizable.xcstrings | 7 - .../MainContentCoordinator+Navigation.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 15 +- .../Views/Sidebar/SidebarContextMenu.swift | 11 -- TablePro/Views/Sidebar/SidebarView.swift | 21 ++- .../SyncRecordMapperFavoriteTableTests.swift | 1 - .../Storage/RecentTablesStoreTests.swift | 5 - TableProUITests/TableProLaunchUITests.swift | 6 +- 16 files changed, 125 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index 9c6ddea64..c3d1be70c 100644 --- a/.gitignore +++ b/.gitignore @@ -156,4 +156,3 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ .docs/ -Local.xcconfig diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e024f33..5976bf1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. -- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are pinned to the top of their section, appear in a dedicated Tables group in the Favorites tab, and sync through iCloud. +- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are scoped to the connection 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 next to the sidebar filter creates a new table without right-clicking. The button is 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. (#1352) - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) @@ -23,9 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clearing a query with the trash button now also clears its results, and a new Clear Results item on the results right-click menu clears results on their own. (#1256) - Inserting SQL from AI Chat opens it in a new query tab instead of appending to the current query. An empty editor is filled in place. (#1257) - -### 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. diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 47279e7f0..0eb3f2aa9 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -551,7 +551,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi resized = true } if resized { - window.setFrame(frame, display: true, animate: window.isVisible) + window.setFrame(frame, display: true, animate: false) } } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index d212298ef..d2feb642a 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -197,6 +197,8 @@ final class ConnectionStorage { let appSettings = appSettingsProvider() appSettings.saveLastDatabase(nil, for: connection.id) appSettings.saveLastSchema(nil, for: connection.id) + + FavoriteTablesStorage.shared.removeFavorites(for: connection.id) } /// Batch-delete multiple connections and clean up their Keychain entries @@ -223,6 +225,7 @@ final class ConnectionStorage { let appSettings = appSettingsProvider() appSettings.saveLastDatabase(nil, for: conn.id) appSettings.saveLastSchema(nil, for: conn.id) + FavoriteTablesStorage.shared.removeFavorites(for: conn.id) } } diff --git a/TablePro/Core/Storage/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift index 37281b6f2..02113db7f 100644 --- a/TablePro/Core/Storage/FavoriteTablesStorage.swift +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -1,4 +1,3 @@ - import Foundation import os @@ -47,61 +46,80 @@ final class FavoriteTablesStorage { func toggle(name: String, schema: String?, connectionId: UUID) { let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) - lock.lock() - var favorites = _loadFavorites() - let isPresent = favorites.contains(entry) - lock.unlock() - - if isPresent { - removeFavorite(name: name, schema: schema, connectionId: connectionId) - } else { - addFavorite(name: name, schema: schema, connectionId: connectionId) + 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) } - func addFavorite(name: String, schema: String?, connectionId: UUID) { + @discardableResult + func addFavorite(name: String, schema: String?, connectionId: UUID) -> Bool { let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) - lock.lock() - var favorites = _loadFavorites() - guard favorites.insert(entry).inserted else { lock.unlock(); return } - _persist(favorites) - lock.unlock() - syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: entry)) + let action: TrackedAction = mutate { favorites in + guard favorites.insert(entry).inserted else { return .noChange } + return .added(entry) + } + notify(after: action) + return action.changed } - func addFavoriteWithoutSync(_ entry: FavoriteEntry) { - lock.lock() - var favorites = _loadFavorites() - guard favorites.insert(entry).inserted else { lock.unlock(); return } - _persist(favorites) - lock.unlock() + @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?, connectionId: UUID) { let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) - lock.lock() - var favorites = _loadFavorites() - guard favorites.remove(entry) != nil else { lock.unlock(); return } - _persist(favorites) - lock.unlock() - syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + let action = mutate { favorites in + favorites.remove(entry) != nil ? .removed(entry) : .noChange + } + notify(after: action) } func removeFavoriteWithoutSync(_ entry: FavoriteEntry) { - lock.lock() - var favorites = _loadFavorites() - guard favorites.remove(entry) != nil else { lock.unlock(); return } - _persist(favorites) - lock.unlock() + 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() - guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { lock.unlock(); return } - favorites.remove(entry) - _persist(favorites) + 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 { @@ -109,6 +127,44 @@ final class FavoriteTablesStorage { 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), @@ -127,6 +183,5 @@ final class FavoriteTablesStorage { return } defaults.set(data, forKey: key) - NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) } } diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index 431b1727c..56507e8ff 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -1,8 +1,3 @@ -// -// RecentTablesStore.swift -// TablePro -// - import Foundation extension Notification.Name { diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index 280ef16bd..91f4ce195 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -640,9 +640,7 @@ final class SyncCoordinator { return false } if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: entry)) { return false } - let before = services.favoriteTablesStorage.loadFavorites() - services.favoriteTablesStorage.addFavoriteWithoutSync(entry) - return before != services.favoriteTablesStorage.loadFavorites() + return services.favoriteTablesStorage.addFavoriteWithoutSync(entry) } // MARK: - Observers diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 83e375dbe..5211b805b 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -332,7 +332,6 @@ struct SyncRecordMapper { let recordID = recordID(type: .tableFavorite, id: favoriteId, in: zone) let record = CKRecord(recordType: SyncRecordType.tableFavorite.rawValue, recordID: recordID) - record["favoriteTableId"] = favoriteId as CKRecordValue record["connectionId"] = entry.connectionId.uuidString as CKRecordValue record["name"] = entry.name as CKRecordValue if let schema = entry.schema { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 99a3bda2e..0d79c6626 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1939,7 +1939,6 @@ } }, "%lld of %lld" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -22453,7 +22452,6 @@ } }, "Format JSON" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27294,7 +27292,6 @@ } }, "Limit" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30212,7 +30209,6 @@ } }, "Next Page (⌘])" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32648,7 +32644,6 @@ } }, "Offset" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33899,7 +33894,6 @@ } }, "Pagination Settings" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35930,7 +35924,6 @@ } }, "Previous Page (⌘[)" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 4cd8b9d74..1f248411d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -23,7 +23,7 @@ extension MainContentCoordinator { ) { RecentTablesStore.shared.push( connectionID: connection.id, - database: activeDatabaseName.nilIfEmpty, + database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, table: table ) openTableTab( diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 75f9ac712..ec17055a8 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -1,8 +1,3 @@ -// -// FavoritesTabView.swift -// TablePro -// - import SwiftUI internal struct FavoritesTabView: View { @@ -190,7 +185,7 @@ internal struct FavoritesTabView: View { Image(systemName: "star.fill") .foregroundStyle(.yellow) } - .tag(tableNodeId(table.name)) + .tag(tableNodeId(table)) .contextMenu { favoriteTableContextMenu(table) } @@ -215,14 +210,14 @@ internal struct FavoritesTabView: View { } } - private func tableNodeId(_ name: String) -> String { - "table:\(name)" + private func tableNodeId(_ table: TableInfo) -> String { + let suffix = table.schema.map { "\($0).\(table.name)" } ?? table.name + return "table:\(suffix)" } private func favoriteTable(forNodeId nodeId: String) -> TableInfo? { guard nodeId.hasPrefix("table:") else { return nil } - let name = String(nodeId.dropFirst("table:".count)) - return availableFavoriteTables.first { $0.name == name } + return availableFavoriteTables.first { tableNodeId($0) == nodeId } } @ViewBuilder diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index f2e8e3b5f..54d787e0d 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: @@ -46,8 +38,6 @@ enum SidebarContextMenuLogic { } } - /// True when the Maintenance group has at least one runnable child. - /// Disables the parent menu when every child action is unreachable. static func maintenanceGroupEnabled( isReadOnly: Bool, hasSelection: Bool, @@ -58,7 +48,6 @@ enum SidebarContextMenuLogic { } } -/// Unified context menu for sidebar — used for both table rows and empty space struct SidebarContextMenu: View { let clickedTable: TableInfo? let selectedTables: Set diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 033135a4c..00ed83f1d 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -248,8 +248,17 @@ struct SidebarView: View { 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, + schema: table.schema, + name: table.name + )) + } + private func reloadRecentTables() { - let database = coordinator?.activeDatabaseName.nilIfEmpty + let activeName = coordinator?.activeDatabaseName ?? "" + let database: String? = activeName.isEmpty ? nil : activeName recentTables = RecentTablesStore.shared.entries( connectionID: connectionId, database: database @@ -267,10 +276,14 @@ struct SidebarView: View { table: info, isPendingTruncate: pendingTruncates.contains(info.name), isPendingDelete: pendingDeletes.contains(info.name), - isFavorite: favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: info.schema, name: info.name)), + isFavorite: isFavorite(info), onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: info.name, schema: info.schema, connectionId: connectionId) } ) - .tag(info) + .selectionDisabled() + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + onDoubleClick?(info) + } .contextMenu { SidebarContextMenu( clickedTable: info, @@ -377,7 +390,7 @@ struct SidebarView: View { table: table, isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), - isFavorite: favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: table.schema, name: table.name)), + isFavorite: isFavorite(table), onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: table.name, schema: table.schema, connectionId: connectionId) } ) .tag(table) diff --git a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift index 301a7c179..2524f30eb 100644 --- a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -17,7 +17,6 @@ struct SyncRecordMapperFavoriteTableTests { let id = FavoriteTablesStorage.syncId(for: entry) #expect(record.recordType == SyncRecordType.tableFavorite.rawValue) #expect(record.recordID.recordName == "FavoriteTable_\(id)") - #expect(record["favoriteTableId"] as? String == id) #expect(record["name"] as? String == "users") #expect(record["connectionId"] as? String == connId.uuidString) #expect(record["schema"] as? String == "public") diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift index 949647f87..78ab95a10 100644 --- a/TableProTests/Storage/RecentTablesStoreTests.swift +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -1,8 +1,3 @@ -// -// RecentTablesStoreTests.swift -// TableProTests -// - import Foundation import Testing diff --git a/TableProUITests/TableProLaunchUITests.swift b/TableProUITests/TableProLaunchUITests.swift index adc61e636..3855724e9 100644 --- a/TableProUITests/TableProLaunchUITests.swift +++ b/TableProUITests/TableProLaunchUITests.swift @@ -17,7 +17,7 @@ final class TableProLaunchUITests: XCTestCase { XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) } - func testMainWindowRespectsMinimumSize() throws { + func testMainWindowLaunchesAtOrAboveBaseMinimum() throws { let app = XCUIApplication() app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" app.launch() @@ -26,7 +26,7 @@ final class TableProLaunchUITests: XCTestCase { XCTAssertTrue(window.waitForExistence(timeout: 10)) let frame = window.frame - XCTAssertGreaterThanOrEqual(frame.width, 950, "Window width must be at least the base minimum") - XCTAssertGreaterThanOrEqual(frame.height, 480, "Window height must be at least the base minimum") + 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)") } } From ceb4edbf4297ddecbb9620518ffae4d64479c2f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 03:56:59 +0000 Subject: [PATCH 08/28] @salmonumbrella has signed the CLA in TableProApp/TablePro#1476 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From a75f17bdfe9b81219b02fb94e3b15540c2ef967d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:25:42 +0700 Subject: [PATCH 09/28] fix(sidebar): scope table favorites by database --- CHANGELOG.md | 4 +- .../Core/Storage/FavoriteTablesStorage.swift | 24 ++++++----- TablePro/Core/Sync/SyncRecordMapper.swift | 11 ++++- TablePro/Views/Sidebar/FavoritesTabView.swift | 23 +++++++++-- TablePro/Views/Sidebar/SidebarView.swift | 23 ++++++++--- .../Storage/FavoriteTablesStorageTests.swift | 40 ++++++++++++------- .../SyncRecordMapperFavoriteTableTests.swift | 35 ++++++++++++---- docs/features/favorites.mdx | 4 +- 8 files changed, 120 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5976bf1db..74b879c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - BigQuery: the sidebar now shows every dataset as an expandable node, with each dataset's tables loading when you open it, instead of showing one dataset at a time behind a picker. -- Mark a table as a favorite by clicking the star button at the end of its sidebar row. Favorites are scoped to the connection 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 next to the sidebar filter creates a new table without right-clicking. The button is disabled while safe mode blocks writes. +- 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 next to the sidebar filter creates a new table without right-clicking. It shows only on the Tables tab and is 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. (#1352) - OpenCode Zen as an AI provider. Add it from the provider list and paste an OpenCode key, or leave the key blank to use the free models; the model list loads automatically, covering the Claude, GPT, Gemini, and open models Zen serves. (#1400) - Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425) diff --git a/TablePro/Core/Storage/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift index 02113db7f..14dc02131 100644 --- a/TablePro/Core/Storage/FavoriteTablesStorage.swift +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -11,6 +11,7 @@ final class FavoriteTablesStorage { struct FavoriteEntry: Codable, Hashable { let connectionId: UUID + let database: String? let schema: String? let name: String } @@ -38,14 +39,16 @@ final class FavoriteTablesStorage { return _loadFavorites().filter { $0.connectionId == connectionId } } - func isFavorite(name: String, schema: String?, connectionId: UUID) -> Bool { + func isFavorite(name: String, schema: String?, database: String?, connectionId: UUID) -> Bool { lock.lock() defer { lock.unlock() } - return _loadFavorites().contains(FavoriteEntry(connectionId: connectionId, schema: schema, name: name)) + return _loadFavorites().contains( + FavoriteEntry(connectionId: connectionId, database: database, schema: schema, name: name) + ) } - func toggle(name: String, schema: String?, connectionId: UUID) { - let entry = FavoriteEntry(connectionId: connectionId, 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) @@ -58,8 +61,8 @@ final class FavoriteTablesStorage { } @discardableResult - func addFavorite(name: String, schema: String?, connectionId: UUID) -> Bool { - let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + 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) @@ -77,8 +80,8 @@ final class FavoriteTablesStorage { return action.changed } - func removeFavorite(name: String, schema: String?, connectionId: UUID) { - let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + 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 } @@ -123,7 +126,10 @@ final class FavoriteTablesStorage { } static func syncId(for entry: FavoriteEntry) -> String { - let raw = entry.connectionId.uuidString + "|" + (entry.schema ?? "") + "|" + entry.name + let raw = entry.connectionId.uuidString + + "|" + (entry.database ?? "") + + "|" + (entry.schema ?? "") + + "|" + entry.name return raw.sha256 } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 5211b805b..fd6fb1ade 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -334,6 +334,9 @@ struct SyncRecordMapper { 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 } @@ -351,8 +354,14 @@ struct SyncRecordMapper { 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, schema: schema, name: name) + return FavoriteTablesStorage.FavoriteEntry( + connectionId: connectionId, + database: database, + schema: schema, + name: name + ) } // MARK: - SSH Profile diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index ec17055a8..bb7047439 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -18,12 +18,27 @@ internal struct FavoritesTabView: View { 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] { - favoriteTables.compactMap { entry in - tables.first { $0.name == entry.name && $0.schema == entry.schema } + 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 @@ -204,7 +219,7 @@ internal struct FavoritesTabView: View { Divider() Button(role: .destructive) { - FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId) } label: { Text(String(localized: "Remove from Favorites")) } @@ -252,7 +267,7 @@ internal struct FavoritesTabView: View { private func deleteSelectedNode() { guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } if let table = favoriteTable(forNodeId: nodeId) { - FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId) return } if let fav = viewModel.favoriteForNodeId(nodeId) { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 00ed83f1d..7a3d5637b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -241,6 +241,11 @@ struct SidebarView: View { 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 @@ -251,17 +256,25 @@ struct SidebarView: View { 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() { - let activeName = coordinator?.activeDatabaseName ?? "" - let database: String? = activeName.isEmpty ? nil : activeName recentTables = RecentTablesStore.shared.entries( connectionID: connectionId, - database: database + database: activeDatabase ) } @@ -277,7 +290,7 @@ struct SidebarView: View { isPendingTruncate: pendingTruncates.contains(info.name), isPendingDelete: pendingDeletes.contains(info.name), isFavorite: isFavorite(info), - onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: info.name, schema: info.schema, connectionId: connectionId) } + onToggleFavorite: { toggleFavorite(info) } ) .selectionDisabled() .contentShape(Rectangle()) @@ -391,7 +404,7 @@ struct SidebarView: View { isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), isFavorite: isFavorite(table), - onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: table.name, schema: table.schema, connectionId: connectionId) } + onToggleFavorite: { toggleFavorite(table) } ) .tag(table) .contextMenu { diff --git a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift index 636609e33..5f47f85a4 100644 --- a/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -1,4 +1,3 @@ - import Foundation @testable import TablePro import Testing @@ -23,9 +22,9 @@ struct FavoriteTablesStorageTests { func addMarksDirty() throws { let (storage, metadata) = try makeStorage() let connId = UUID() - storage.addFavorite(name: "users", schema: nil, connectionId: connId) + storage.addFavorite(name: "users", schema: nil, database: nil, connectionId: connId) - let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "users") + 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]) @@ -35,10 +34,10 @@ struct FavoriteTablesStorageTests { func removeCreatesTombstone() throws { let (storage, metadata) = try makeStorage() let connId = UUID() - storage.addFavorite(name: "users", schema: nil, connectionId: connId) - storage.removeFavorite(name: "users", schema: nil, connectionId: connId) + 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, schema: nil, name: "users") + 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) @@ -49,7 +48,7 @@ struct FavoriteTablesStorageTests { func withoutSyncDoesNotTrackChanges() throws { let (storage, metadata) = try makeStorage() let connId = UUID() - let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "orders") + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, database: nil, schema: nil, name: "orders") storage.addFavoriteWithoutSync(entry) storage.removeFavoriteWithoutSync(entry) @@ -63,8 +62,8 @@ struct FavoriteTablesStorageTests { let (storage, _) = try makeStorage() let connA = UUID() let connB = UUID() - storage.addFavorite(name: "users", schema: nil, connectionId: connA) - storage.addFavorite(name: "users", schema: nil, connectionId: connB) + 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) @@ -79,19 +78,32 @@ struct FavoriteTablesStorageTests { func schemaQualifiedIsDistinct() throws { let (storage, _) = try makeStorage() let connId = UUID() - storage.addFavorite(name: "users", schema: "public", connectionId: connId) - storage.addFavorite(name: "users", schema: "app", connectionId: connId) - storage.addFavorite(name: "users", schema: nil, connectionId: connId) + 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, connectionId: connId) - storage.toggle(name: "orders", schema: nil, connectionId: connId) + 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 index 2524f30eb..5327866c0 100644 --- a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -1,4 +1,3 @@ - import CloudKit import Foundation @testable import TablePro @@ -11,7 +10,9 @@ struct SyncRecordMapperFavoriteTableTests { @Test("Table favorite record round trips all fields") func tableFavoriteRoundTrip() throws { let connId = UUID() - let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: "public", name: "users") + 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) @@ -19,29 +20,49 @@ struct SyncRecordMapperFavoriteTableTests { #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 schema round trips correctly") - func tableFavoriteNoSchemaRoundTrip() throws { + @Test("Table favorite without database or schema round trips correctly") + func tableFavoriteNoDatabaseNoSchemaRoundTrip() throws { let connId = UUID() - let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "orders") + 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, schema: nil, name: "users") - let entryB = FavoriteTablesStorage.FavoriteEntry(connectionId: connB, schema: nil, name: "users") + 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/docs/features/favorites.mdx b/docs/features/favorites.mdx index 9f15e67a8..83b4df576 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -14,9 +14,9 @@ Every table row in the sidebar has a star button at the end. Click it to add or - 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, view its focused ER diagram, or remove it. +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 stored by table name and sync through iCloud. If a favorited table name doesn't exist in the active connection, it is hidden. +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 From 7c522c33702a185d2d23cf7943669ac6d8add69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:25:49 +0700 Subject: [PATCH 10/28] fix(sidebar): show create-table button only on the Tables tab --- .../SidebarContainerViewController.swift | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index d26baa7c4..3f6625cc0 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -15,6 +15,8 @@ internal final class SidebarContainerViewController: NSViewController { private var windowState: WindowSidebarState? private weak var coordinator: MainContentCoordinator? private var observationGeneration = 0 + private var searchTrailingToButton: NSLayoutConstraint? + private var searchTrailingToView: NSLayoutConstraint? var rootView: AnyView { get { hostingController.rootView } @@ -50,10 +52,19 @@ internal final class SidebarContainerViewController: NSViewController { hostingView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingView) + let searchTrailingToButton = searchField.trailingAnchor.constraint( + equalTo: createTableButton.leadingAnchor, constant: -6 + ) + let searchTrailingToView = searchField.trailingAnchor.constraint( + equalTo: view.trailingAnchor, constant: -10 + ) + self.searchTrailingToButton = searchTrailingToButton + self.searchTrailingToView = searchTrailingToView + NSLayoutConstraint.activate([ searchField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), - searchField.trailingAnchor.constraint(equalTo: createTableButton.leadingAnchor, constant: -6), + searchTrailingToButton, createTableButton.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), createTableButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), @@ -98,7 +109,7 @@ internal final class SidebarContainerViewController: NSViewController { self.coordinator = coordinator guard let state, let windowState else { searchField.isHidden = true - createTableButton.isHidden = true + setCreateTableButtonVisible(false) createTableButton.isEnabled = false return } @@ -150,13 +161,20 @@ internal final class SidebarContainerViewController: NSViewController { } private func syncCreateTableEnabled() { - createTableButton.isHidden = false - guard let coordinator else { + let showButton = sidebarState?.selectedSidebarTab == .tables && coordinator != nil + setCreateTableButtonVisible(showButton) + guard showButton, let coordinator else { createTableButton.isEnabled = false return } createTableButton.isEnabled = !coordinator.safeModeLevel.blocksAllWrites } + + private func setCreateTableButtonVisible(_ visible: Bool) { + createTableButton.isHidden = !visible + searchTrailingToButton?.isActive = visible + searchTrailingToView?.isActive = !visible + } } extension SidebarContainerViewController: NSSearchFieldDelegate { From 93f32677cecc3a8055c56efb2e7887654907a44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:25:49 +0700 Subject: [PATCH 11/28] fix(launch): skip live iCloud sync under TABLEPRO_UI_TESTING --- TablePro/AppDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 4812863a6..744957125 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -16,6 +16,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var hasRunPostLaunchActivation = false + private static var isUITesting: Bool { + ProcessInfo.processInfo.environment["TABLEPRO_UI_TESTING"] == "1" + } + // MARK: - URL & File Open func applicationWillFinishLaunching(_ notification: Notification) { @@ -93,12 +97,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidBecomeActive(_ notification: Notification) { runPostLaunchActivationIfNeeded() + guard !Self.isUITesting else { return } SyncCoordinator.shared.syncIfNeeded() } private func runPostLaunchActivationIfNeeded() { guard !hasRunPostLaunchActivation else { return } hasRunPostLaunchActivation = true + guard !Self.isUITesting else { return } ConnectionStorage.shared.migratePluginSecureFieldsIfNeeded() AnalyticsService.shared.startPeriodicHeartbeat() From 11e5772690e4e04ad0f70ae372087a717cd75eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:25:50 +0700 Subject: [PATCH 12/28] refactor(sidebar): drop unused RecentTablesStore lastAccessedAt --- TablePro/Core/Storage/RecentTablesStore.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index 56507e8ff..1c1653c4a 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -17,7 +17,6 @@ final class RecentTablesStore { let name: String let schema: String? let type: TableInfo.TableType - let lastAccessedAt: Date var id: String { schema.map { "\($0).\(name)" } ?? name } } @@ -36,8 +35,7 @@ final class RecentTablesStore { Entry( name: table.name, schema: table.schema, - type: table.type, - lastAccessedAt: Date() + type: table.type ), at: 0 ) From b9764dd7dbb6e7e8ec409d9dc26020fdc97c1208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:33:21 +0700 Subject: [PATCH 13/28] fix(sync): propagate connection group membership changes to other devices (#1477) * fix(sync): propagate connection group membership changes to other devices * refactor(connections): route reorder through updateConnections and batch dirty marking --- CHANGELOG.md | 4 ++ TablePro/Core/Storage/ConnectionStorage.swift | 7 +-- TablePro/Core/Storage/GroupStorage.swift | 8 ++-- TablePro/ViewModels/WelcomeViewModel.swift | 26 ++++++----- .../Core/Storage/GroupStorageTests.swift | 43 ++++++++++++++++++- 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..9e81d4d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 903124914..9ea56aac8 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -189,9 +189,10 @@ final class ConnectionStorage { guard didMutate, saveConnections(connections) else { return false } - for connection in updatesById.values where !connection.localOnly && !connection.isSample { - syncTracker.markDirty(.connection, id: connection.id.uuidString) - } + let dirtyIds = updatesById.values + .filter { !$0.localOnly && !$0.isSample } + .map { $0.id.uuidString } + syncTracker.markDirty(.connection, ids: dirtyIds) return true } 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/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/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 35d1d3be6..1581bf01c 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: 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() { From 089807f5f4b0e71564ef369c339525bb6bd2c1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:58:47 +0700 Subject: [PATCH 14/28] fix(sidebar): use system colors in TableRowLogic to match color tests --- TablePro/Views/Sidebar/TableRowView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 4607d183b..6d77e7144 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -40,20 +40,20 @@ enum TableRowLogic { } 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 } } From d8adcedf03077f7d5268c0fa4684a87600e32715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 13:04:06 +0700 Subject: [PATCH 15/28] feat(connections): resolve connection passwords from file, env, or command (#1254) (#1478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connections): resolve connection passwords from file, env, or command (#1254) * refactor(connections): cap command output, precise timeout, log malformed source (#1254) * fix(connections): preserve password source across tunnel, duplicate, form save, and sync (#1254) --------- Signed-off-by: Ngô Quốc Đạt --- CHANGELOG.md | 4 + TablePro/Core/Database/DatabaseDriver.swift | 3 + .../Database/DatabaseManager+Tunnel.swift | 1 + TablePro/Core/Storage/ConnectionStorage.swift | 11 + TablePro/Core/Sync/SyncCoordinator.swift | 1 + TablePro/Core/Sync/SyncRecordMapper.swift | 2 + .../Connection/PasswordSourceResolver.swift | 237 ++++++++++++++++++ .../Connection/DatabaseConnection.swift | 6 + .../Models/Connection/PasswordSource.swift | 73 ++++++ .../ConnectionFormCoordinator.swift | 2 + .../Database/DatabaseManagerTunnelTests.swift | 30 +++ .../ConnectionStoragePersistenceTests.swift | 14 ++ .../PasswordSourceResolverTests.swift | 200 +++++++++++++++ .../Models/PasswordSourceCodableTests.swift | 70 ++++++ docs/features/connection-sharing.mdx | 18 ++ 15 files changed, 672 insertions(+) create mode 100644 TablePro/Core/Utilities/Connection/PasswordSourceResolver.swift create mode 100644 TablePro/Models/Connection/PasswordSource.swift create mode 100644 TableProTests/Core/Database/DatabaseManagerTunnelTests.swift create mode 100644 TableProTests/Core/Utilities/PasswordSourceResolverTests.swift create mode 100644 TableProTests/Models/PasswordSourceCodableTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e81d4d0a..4b338421a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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) + ### 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/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 3181e4892..5633acda8 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -479,6 +479,9 @@ enum DatabaseDriverFactory { return try await resolveIAMPassword(for: connection, fields: fields) } if let override { return override } + if let passwordSource = connection.passwordSource { + return try await PasswordSourceResolver.resolve(passwordSource) + } if connection.usePgpass { let pgpassHost = connection.additionalFields["pgpassOriginalHost"] ?? connection.host let pgpassPort = connection.additionalFields["pgpassOriginalPort"] diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 39daef5c0..95147a9ce 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -41,6 +41,7 @@ extension DatabaseManager { type: connection.type, sshConfig: SSHConfiguration(), sslConfig: tunnelSSL, + passwordSource: connection.passwordSource, additionalFields: effectiveFields ) } diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 9ea56aac8..5d5287479 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -278,6 +278,7 @@ final class ConnectionStorage { startupCommands: connection.startupCommands, sortOrder: connection.sortOrder, localOnly: connection.localOnly, + passwordSource: connection.passwordSource, additionalFields: connection.additionalFields.isEmpty ? nil : connection.additionalFields ) @@ -591,6 +592,9 @@ private struct StoredConnection: Codable { // Plugin-driven additional fields let additionalFields: [String: String]? + // Password source (file, env, or command) for connections provisioned outside the app + let passwordSource: PasswordSource? + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -676,6 +680,9 @@ private struct StoredConnection: Codable { // Plugin-driven additional fields self.additionalFields = connection.additionalFields.isEmpty ? nil : connection.additionalFields + + // Password source (not synced to iCloud; see SyncRecordMapper) + self.passwordSource = connection.passwordSource } private enum CodingKeys: String, CodingKey { @@ -698,6 +705,7 @@ private struct StoredConnection: Codable { case localOnly case isSample case isFavorite + case passwordSource } func encode(to encoder: Encoder) throws { @@ -741,6 +749,7 @@ private struct StoredConnection: 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) } // Custom decoder to handle migration from old format @@ -807,6 +816,7 @@ private struct StoredConnection: Codable { sshTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .sshTunnelModeJson) cloudflareTunnelModeJson = try container.decodeIfPresent(Data.self, forKey: .cloudflareTunnelModeJson) additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields) + passwordSource = PasswordSource.resilientlyDecoded(from: container, forKey: .passwordSource) 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 @@ -914,6 +924,7 @@ private struct StoredConnection: Codable { localOnly: localOnly, isSample: isSample, isFavorite: isFavorite, + passwordSource: passwordSource, additionalFields: mergedFields ) } diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fa0fd3577..56f55c590 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -517,6 +517,7 @@ final class SyncCoordinator { } var merged = remoteConnection merged.localOnly = connections[index].localOnly + merged.passwordSource = connections[index].passwordSource connections[index] = merged } else { connections.append(remoteConnection) diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 6eba88390..50d7e2b65 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -113,6 +113,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 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/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/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/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/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/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`. From 84deed7a836d63680e85bc1ad451cbdfacf2b300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 13:34:30 +0700 Subject: [PATCH 16/28] refactor(sidebar): move create-table action into the sidebar bottom bar --- CHANGELOG.md | 2 +- .../MainSplitViewController.swift | 9 +-- .../SidebarContainerViewController.swift | 71 +------------------ ...Footer.swift => SchemaPickerControl.swift} | 25 +++---- TablePro/Views/Sidebar/SidebarView.swift | 35 +++++++-- docs/features/table-operations.mdx | 2 +- 6 files changed, 49 insertions(+), 95 deletions(-) rename TablePro/Views/Sidebar/{SchemaPickerFooter.swift => SchemaPickerControl.swift} (89%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2be89af..111f6a683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 next to the sidebar filter creates a new table without right-clicking. It shows only on the Tables tab and is disabled while safe mode blocks writes. +- 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. (#1352) ### Changed diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 0eb3f2aa9..37c71314a 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -174,8 +174,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi } else if let session = currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(session.connection.id), - windowState: coordinator.windowSidebarState, - coordinator: coordinator + windowState: coordinator.windowSidebarState ) } inspectorSplitItem.isCollapsed = !inspectorPresented @@ -210,8 +209,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState, - coordinator: coordinator + windowState: coordinator.windowSidebarState ) } @@ -329,8 +327,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi if let currentSession, let coordinator = sessionState?.coordinator { sidebarContainer.updateSidebarState( SharedSidebarState.forConnection(currentSession.connection.id), - windowState: coordinator.windowSidebarState, - coordinator: coordinator + windowState: coordinator.windowSidebarState ) } detailHosting.rootView = AnyView(buildDetailView()) diff --git a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 3f6625cc0..9b43e6fee 100644 --- a/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -9,14 +9,10 @@ import SwiftUI @MainActor internal final class SidebarContainerViewController: NSViewController { private let searchField = NSSearchField() - private let createTableButton = NSButton() private var hostingController: NSHostingController private var sidebarState: SharedSidebarState? private var windowState: WindowSidebarState? - private weak var coordinator: MainContentCoordinator? private var observationGeneration = 0 - private var searchTrailingToButton: NSLayoutConstraint? - private var searchTrailingToView: NSLayoutConstraint? var rootView: AnyView { get { hostingController.rootView } @@ -44,32 +40,15 @@ internal final class SidebarContainerViewController: NSViewController { searchField.setAccessibilityIdentifier("sidebar-filter") view.addSubview(searchField) - configureCreateTableButton() - view.addSubview(createTableButton) - addChild(hostingController) let hostingView = hostingController.view hostingView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingView) - let searchTrailingToButton = searchField.trailingAnchor.constraint( - equalTo: createTableButton.leadingAnchor, constant: -6 - ) - let searchTrailingToView = searchField.trailingAnchor.constraint( - equalTo: view.trailingAnchor, constant: -10 - ) - self.searchTrailingToButton = searchTrailingToButton - self.searchTrailingToView = searchTrailingToView - NSLayoutConstraint.activate([ searchField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), searchField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), - searchTrailingToButton, - - createTableButton.centerYAnchor.constraint(equalTo: searchField.centerYAnchor), - createTableButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), - createTableButton.widthAnchor.constraint(equalToConstant: 22), - createTableButton.heightAnchor.constraint(equalToConstant: 22), + searchField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), hostingView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 5), hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -78,44 +57,16 @@ internal final class SidebarContainerViewController: NSViewController { ]) } - private func configureCreateTableButton() { - createTableButton.translatesAutoresizingMaskIntoConstraints = false - createTableButton.bezelStyle = .accessoryBarAction - createTableButton.isBordered = false - createTableButton.image = NSImage(systemSymbolName: "plus", accessibilityDescription: nil) - createTableButton.imageScaling = .scaleProportionallyDown - createTableButton.contentTintColor = .secondaryLabelColor - createTableButton.toolTip = String(localized: "Create New Table") - createTableButton.setAccessibilityLabel(String(localized: "Create New Table")) - createTableButton.setAccessibilityIdentifier("sidebar-create-table") - createTableButton.target = self - createTableButton.action = #selector(handleCreateTableClicked(_:)) - createTableButton.isEnabled = false - createTableButton.isHidden = true - } - - @objc private func handleCreateTableClicked(_ sender: Any?) { - coordinator?.createNewTable() - } - - func updateSidebarState( - _ state: SharedSidebarState?, - windowState: WindowSidebarState?, - coordinator: MainContentCoordinator? = nil - ) { + func updateSidebarState(_ state: SharedSidebarState?, windowState: WindowSidebarState?) { observationGeneration += 1 self.sidebarState = state self.windowState = windowState - self.coordinator = coordinator guard let state, let windowState else { searchField.isHidden = true - setCreateTableButtonVisible(false) - createTableButton.isEnabled = false return } searchField.isHidden = false syncFromState(state, windowState: windowState) - syncCreateTableEnabled() startObserving(state, windowState: windowState, generation: observationGeneration) } @@ -128,7 +79,6 @@ internal final class SidebarContainerViewController: NSViewController { _ = state.selectedSidebarTab _ = windowState.searchText _ = windowState.favoritesSearchText - _ = coordinator?.safeModeLevel } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self, @@ -136,7 +86,6 @@ internal final class SidebarContainerViewController: NSViewController { let sidebarState = self.sidebarState, let windowState = self.windowState else { return } self.syncFromState(sidebarState, windowState: windowState) - self.syncCreateTableEnabled() self.startObserving(sidebarState, windowState: windowState, generation: generation) } } @@ -159,22 +108,6 @@ internal final class SidebarContainerViewController: NSViewController { } searchField.placeholderString = placeholder } - - private func syncCreateTableEnabled() { - let showButton = sidebarState?.selectedSidebarTab == .tables && coordinator != nil - setCreateTableButtonVisible(showButton) - guard showButton, let coordinator else { - createTableButton.isEnabled = false - return - } - createTableButton.isEnabled = !coordinator.safeModeLevel.blocksAllWrites - } - - private func setCreateTableButtonVisible(_ visible: Bool) { - createTableButton.isHidden = !visible - searchTrailingToButton?.isActive = visible - searchTrailingToView?.isActive = !visible - } } extension SidebarContainerViewController: NSSearchFieldDelegate { 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/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 7a3d5637b..ddf76222b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -102,10 +102,8 @@ 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 { @@ -159,6 +157,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) { diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index daf46a362..7fbac079c 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -9,7 +9,7 @@ Right-click tables in the sidebar to drop, truncate, run maintenance, or manage ## Create Table -Click the plus button next to the sidebar filter to open a Create Table tab. The button is disabled while safe mode blocks writes. +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 From 5bf506db25738236027c548339f04b7729d27e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 13:51:31 +0700 Subject: [PATCH 17/28] refactor(sidebar): reveal favorite star on hover and refine favorites/recents UX --- CHANGELOG.md | 5 ++- TablePro/Views/Sidebar/FavoritesTabView.swift | 6 ++-- .../Views/Sidebar/SidebarContextMenu.swift | 4 +++ TablePro/Views/Sidebar/SidebarView.swift | 2 +- TablePro/Views/Sidebar/TableRowView.swift | 33 ++++++++++++++++--- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 111f6a683..e0ed401c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. (#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 @@ -20,9 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- "Create New Table…" from the sidebar right-click menu. Use the plus button next to the sidebar filter instead. -- "View ER Diagram" from the sidebar right-click menu. Access it from the Favorites tab context menu instead. -- 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) +- "Create New Table…" from the sidebar right-click menu. Use the plus button in the Tables sidebar footer instead. ### Fixed diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index bb7047439..d3be4bc99 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -197,8 +197,8 @@ internal struct FavoritesTabView: View { Text(table.name) .font(.system(.callout, design: .monospaced)) } icon: { - Image(systemName: "star.fill") - .foregroundStyle(.yellow) + Image(systemName: TableRowLogic.iconName(for: table.type)) + .foregroundStyle(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false)) } .tag(tableNodeId(table)) .contextMenu { @@ -212,7 +212,7 @@ internal struct FavoritesTabView: View { coordinator?.openTableTab(table) } - Button(String(localized: "View ER Diagram")) { + Button(String(localized: "Show ER Diagram")) { coordinator?.showERDiagram() } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 54d787e0d..7bf7b2c58 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -95,6 +95,10 @@ struct SidebarContextMenu: View { } .disabled(clickedTable == nil) + Button(String(localized: "Show ER Diagram")) { + coordinator?.showERDiagram() + } + Button("Copy Name") { ClipboardService.shared.writeText(effectiveTableNames.joined(separator: ",")) } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index ddf76222b..cd0da0a3b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -321,7 +321,7 @@ struct SidebarView: View { ) .selectionDisabled() .contentShape(Rectangle()) - .onTapGesture(count: 2) { + .onTapGesture { onDoubleClick?(info) } .contextMenu { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6d77e7144..0affc9a3a 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -65,6 +65,8 @@ struct TableRow: View { var isFavorite: Bool = false var onToggleFavorite: (() -> Void)? + @State private var isHovered = false + private var iconColor: Color { TableRowLogic.iconColor(table: table, isPendingDelete: isPendingDelete, isPendingTruncate: isPendingTruncate) } @@ -103,23 +105,25 @@ struct TableRow: View { 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.opacity(0.55)) + .foregroundStyle(isFavorite ? Color.yellow : Color.secondary) .contentShape(Rectangle()) - .frame(width: 16, height: 16) + .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")) - .accessibilityLabel(isFavorite - ? String(localized: "Remove from Favorites") - : String(localized: "Add to Favorites")) } } .padding(.vertical, 4) + .onHover { isHovered = $0 } .accessibilityElement(children: .combine) .accessibilityLabel( TableRowLogic.accessibilityLabel( @@ -129,5 +133,24 @@ struct TableRow: View { 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 + } } } From c35f49f79bdb6634e76c09bc87cf706f150648cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:04:04 +0700 Subject: [PATCH 18/28] fix(sidebar): drop hardcoded footer divider, use native bottom bar (separator on scroll) --- TablePro/Views/Sidebar/FavoritesTabView.swift | 7 +++---- TablePro/Views/Sidebar/SidebarView.swift | 11 ++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d3be4bc99..59bc70da3 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -67,10 +67,7 @@ internal struct FavoritesTabView: View { } } .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - Divider() - bottomToolbar - } + bottomToolbar } .onAppear { SQLFolderWatcher.shared.start() @@ -574,6 +571,8 @@ internal struct FavoritesTabView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.bar) } private func addLinkedFolder() { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cd0da0a3b..179524017 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -100,11 +100,10 @@ struct SidebarView: View { Group { switch sidebarState.selectedSidebarTab { case .tables: - VStack(spacing: 0) { - tablesContent - Divider() - tablesBottomBar - } + tablesContent + .safeAreaInset(edge: .bottom, spacing: 0) { + tablesBottomBar + } case .favorites: if let coordinator { FavoritesTabView( @@ -169,6 +168,8 @@ struct SidebarView: View { } .padding(.horizontal, 8) .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.bar) } private var createObjectMenu: some View { From 09608d2a2572418eae65d810a74e2d7be71f4265 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:11:24 +0700 Subject: [PATCH 19/28] fix(sidebar): footer inherits sidebar vibrancy instead of opaque material --- TablePro/Views/Sidebar/FavoritesTabView.swift | 2 -- TablePro/Views/Sidebar/SidebarView.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 59bc70da3..a79ce36fd 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -571,8 +571,6 @@ internal struct FavoritesTabView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .frame(maxWidth: .infinity) - .background(.bar) } private func addLinkedFolder() { diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 179524017..c451d365e 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -168,8 +168,6 @@ struct SidebarView: View { } .padding(.horizontal, 8) .padding(.vertical, 6) - .frame(maxWidth: .infinity) - .background(.bar) } private var createObjectMenu: some View { From 0b945ba4c35b8cb6177e60104c95bcd8963f1515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:20:55 +0700 Subject: [PATCH 20/28] fix(sidebar): use hard scroll-edge style so footer divider appears on overflow --- TablePro/Views/Sidebar/FavoritesTabView.swift | 1 + TablePro/Views/Sidebar/SidebarView.swift | 1 + TablePro/Views/Sidebar/View+ScrollEdge.swift | 12 ++++++++++++ 3 files changed, 14 insertions(+) create mode 100644 TablePro/Views/Sidebar/View+ScrollEdge.swift diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index a79ce36fd..898da6dd4 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -66,6 +66,7 @@ internal struct FavoritesTabView: View { favoritesList(items, filteredTables: filteredTables) } } + .hardBottomScrollEdge() .safeAreaInset(edge: .bottom, spacing: 0) { bottomToolbar } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index c451d365e..359024127 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -101,6 +101,7 @@ struct SidebarView: View { switch sidebarState.selectedSidebarTab { case .tables: tablesContent + .hardBottomScrollEdge() .safeAreaInset(edge: .bottom, spacing: 0) { tablesBottomBar } diff --git a/TablePro/Views/Sidebar/View+ScrollEdge.swift b/TablePro/Views/Sidebar/View+ScrollEdge.swift new file mode 100644 index 000000000..c801112d7 --- /dev/null +++ b/TablePro/Views/Sidebar/View+ScrollEdge.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + @ViewBuilder + func hardBottomScrollEdge() -> some View { + if #available(macOS 26.0, *) { + scrollEdgeEffectStyle(.hard, for: .bottom) + } else { + self + } + } +} From 532576c1b46ebcf45a2bd78fa329d1075e331321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:26:27 +0700 Subject: [PATCH 21/28] refactor(sidebar): use a static footer divider, drop scroll-edge experiment --- TablePro/Views/Sidebar/FavoritesTabView.swift | 6 ++++-- TablePro/Views/Sidebar/SidebarView.swift | 10 +++++----- TablePro/Views/Sidebar/View+ScrollEdge.swift | 12 ------------ 3 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 TablePro/Views/Sidebar/View+ScrollEdge.swift diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 898da6dd4..d3be4bc99 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -66,9 +66,11 @@ internal struct FavoritesTabView: View { favoritesList(items, filteredTables: filteredTables) } } - .hardBottomScrollEdge() .safeAreaInset(edge: .bottom, spacing: 0) { - bottomToolbar + VStack(spacing: 0) { + Divider() + bottomToolbar + } } .onAppear { SQLFolderWatcher.shared.start() diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 359024127..cd0da0a3b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -100,11 +100,11 @@ struct SidebarView: View { Group { switch sidebarState.selectedSidebarTab { case .tables: - tablesContent - .hardBottomScrollEdge() - .safeAreaInset(edge: .bottom, spacing: 0) { - tablesBottomBar - } + VStack(spacing: 0) { + tablesContent + Divider() + tablesBottomBar + } case .favorites: if let coordinator { FavoritesTabView( diff --git a/TablePro/Views/Sidebar/View+ScrollEdge.swift b/TablePro/Views/Sidebar/View+ScrollEdge.swift deleted file mode 100644 index c801112d7..000000000 --- a/TablePro/Views/Sidebar/View+ScrollEdge.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -extension View { - @ViewBuilder - func hardBottomScrollEdge() -> some View { - if #available(macOS 26.0, *) { - scrollEdgeEffectStyle(.hard, for: .bottom) - } else { - self - } - } -} From 4b7645206810e145be6ff87ff683833bab28cd09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:46:28 +0700 Subject: [PATCH 22/28] refactor(sidebar): remove the Recent tables section --- CHANGELOG.md | 1 - TablePro/Core/Storage/RecentTablesStore.swift | 68 ------------- TablePro/ViewModels/SidebarViewModel.swift | 1 - .../MainContentCoordinator+Navigation.swift | 5 - TablePro/Views/Sidebar/SidebarView.swift | 63 ------------ .../Storage/RecentTablesStoreTests.swift | 95 ------------------- docs/features/favorites.mdx | 6 +- 7 files changed, 1 insertion(+), 238 deletions(-) delete mode 100644 TablePro/Core/Storage/RecentTablesStore.swift delete mode 100644 TableProTests/Storage/RecentTablesStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ed401c1..5c9beb700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. (#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 diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift deleted file mode 100644 index 1c1653c4a..000000000 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ /dev/null @@ -1,68 +0,0 @@ -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/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 21f2f35c3..7719ec365 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -49,7 +49,6 @@ final class SidebarViewModel { ) } } - var isRecentsExpanded: Bool = true var redisKeyTreeViewModel: RedisKeyTreeViewModel? var showOperationDialog = false var pendingOperationType: TableOperationType? diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 1f248411d..813158eff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,11 +21,6 @@ extension MainContentCoordinator { redirectToSibling: Bool = false, forceNonPreview: Bool = false ) { - RecentTablesStore.shared.push( - connectionID: connection.id, - database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, - table: table - ) openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cd0da0a3b..b97a8184b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -12,7 +12,6 @@ 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] = [] var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -262,24 +261,11 @@ 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, @@ -298,53 +284,8 @@ struct SidebarView: View { ) } - private func reloadRecentTables() { - recentTables = RecentTablesStore.shared.entries( - connectionID: connectionId, - database: activeDatabase - ) - } - - @ViewBuilder - private var recentSection: some View { - let recents = filteredRecents - if !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) } @@ -381,12 +322,8 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } - .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in - reloadRecentTables() - } .onAppear { favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) - reloadRecentTables() } } diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift deleted file mode 100644 index 78ab95a10..000000000 --- a/TableProTests/Storage/RecentTablesStoreTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -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/docs/features/favorites.mdx b/docs/features/favorites.mdx index 83b4df576..c6ddb2688 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -5,7 +5,7 @@ description: Mark tables as favorites and save frequently used queries with opti # Favorites -The Tables sidebar shows a **Recent** section at the top with the last 10 tables you opened in the current connection and database. The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. +The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. ## Table Favorites @@ -18,10 +18,6 @@ Double-click a table in the Favorites tab to open it. Right-click it to open the 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 - -Each table you open is added to the **Recent** section at the top of the Tables sidebar. The list 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. From 722cf594098011b7570178c4550c31ec901f4c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 14:55:57 +0700 Subject: [PATCH 23/28] fix(sidebar): drop duplicate context menu and add accessibility label on favorite table rows --- TablePro/Views/Sidebar/FavoritesTabView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d3be4bc99..77f2dbb50 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -191,7 +191,6 @@ internal struct FavoritesTabView: View { } } - @ViewBuilder private func favoriteTableRow(table: TableInfo) -> some View { Label { Text(table.name) @@ -201,9 +200,9 @@ internal struct FavoritesTabView: View { .foregroundStyle(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false)) } .tag(tableNodeId(table)) - .contextMenu { - favoriteTableContextMenu(table) - } + .accessibilityLabel( + TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) + ) } @ViewBuilder From 6685712132fa8ed1f13e53cf28d4f24bfab043df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 15:05:36 +0700 Subject: [PATCH 24/28] refactor(sidebar): type-safe favorite selection and drop AnyView from the favorites tree --- .../ViewModels/ConnectionSidebarState.swift | 12 +- .../FavoritesSidebarViewModel.swift | 46 ++- TablePro/Views/Sidebar/FavoritesTabView.swift | 290 ++++++++++-------- 3 files changed, 198 insertions(+), 150 deletions(-) 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..df120eec6 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -13,6 +13,36 @@ internal struct FavoriteEditItem: Identifiable { let folderId: UUID? } +internal enum FavoriteSelection: Hashable { + case table(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 == 3: + self = .table(schema: parts[1].isEmpty ? nil : parts[1], name: parts[2]) + 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 schema, let name): + return ["table", 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 +383,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/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 77f2dbb50..16cd148de 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -162,7 +162,7 @@ internal struct FavoritesTabView: View { _ items: [FavoriteNode], filteredTables: [TableInfo] ) -> some View { - List(selection: $sidebarState.selectedFavoriteNodeId) { + List(selection: $sidebarState.selectedFavorite) { if !filteredTables.isEmpty { Section(String(localized: "Tables")) { ForEach(filteredTables) { table in @@ -172,7 +172,14 @@ internal struct FavoritesTabView: View { } if !items.isEmpty { Section(String(localized: "Queries")) { - nodeRows(items) + ForEach(items) { node in + FavoriteNodeRow( + node: node, + connectionId: connectionId, + viewModel: viewModel, + isRenameFocused: $isRenameFocused + ) + } } } } @@ -181,13 +188,13 @@ internal struct FavoritesTabView: View { .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) } } @@ -199,7 +206,7 @@ internal struct FavoritesTabView: View { Image(systemName: TableRowLogic.iconName(for: table.type)) .foregroundStyle(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false)) } - .tag(tableNodeId(table)) + .tag(FavoriteSelection.table(schema: table.schema, name: table.name)) .accessibilityLabel( TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) ) @@ -224,142 +231,74 @@ internal struct FavoritesTabView: View { } } - private func tableNodeId(_ table: TableInfo) -> String { - let suffix = table.schema.map { "\($0).\(table.name)" } ?? table.name - return "table:\(suffix)" - } - - private func favoriteTable(forNodeId nodeId: String) -> TableInfo? { - guard nodeId.hasPrefix("table:") else { return nil } - return availableFavoriteTables.first { tableNodeId($0) == nodeId } + private func favoriteTable(schema: String?, name: String) -> TableInfo? { + availableFavoriteTables.first { $0.name == name && $0.schema == schema } } @ViewBuilder - private func contextMenuFor(nodeId: String) -> some View { - if let table = favoriteTable(forNodeId: nodeId) { - favoriteTableContextMenu(table) - } else 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 handlePrimaryAction(nodeId: String) { - if let table = favoriteTable(forNodeId: nodeId) { - coordinator?.openTableTab(table) - return - } - if let fav = viewModel.favoriteForNodeId(nodeId) { - coordinator?.insertFavorite(fav) - return - } - if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { - coordinator?.openLinkedFavorite(linked) - } - } - - private func deleteSelectedNode() { - guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } - if let table = favoriteTable(forNodeId: nodeId) { - FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId) - return - } - if let fav = viewModel.favoriteForNodeId(nodeId) { - viewModel.deleteFavorite(fav) - return - } - if let linked = viewModel.linkedFavoriteForNodeId(nodeId) { - linkedFileToTrash = linked - showTrashLinkedFileAlert = true + private func contextMenu(for selection: FavoriteSelection) -> some View { + switch selection { + case .table(let schema, let name): + if let table = favoriteTable(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 nodeRows(_ items: [FavoriteNode]) -> AnyView { - AnyView(ForEach(items) { node in + private func handlePrimaryAction(_ selection: FavoriteSelection) { + switch selection { + case .table(let schema, let name): + if let table = favoriteTable(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): - 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) + coordinator?.insertFavorite(favorite) case .linkedFavorite(let linked): - LinkedFavoriteRowView(favorite: linked) - .tag(node.id) + coordinator?.openLinkedFavorite(linked) + case .folder, .linkedFolder, .linkedSubfolder: + break } - }) - } - - 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 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 } - ) + private func deleteSelectedNode() { + guard let selection = sidebarState.selectedFavorite else { return } + switch selection { + case .table(let schema, let name): + if let table = favoriteTable(schema: schema, name: name) { + FavoriteTablesStorage.shared.removeFavorite( + name: table.name, schema: table.schema, database: activeDatabase, connectionId: connectionId ) - .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") + 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 + } } } @@ -593,3 +532,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") + } + } +} From 5aa5cf2a0dd04be22d989823e51dca5107c92b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 15:40:07 +0700 Subject: [PATCH 25/28] fix(test): use explicit self for connectionStorage in GroupStorageTests setUp --- TableProTests/Core/Storage/GroupStorageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 1581bf01c..b7141d15f 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -42,7 +42,7 @@ final class GroupStorageTests: XCTestCase { storage = GroupStorage( userDefaults: defaults, syncTracker: tracker, - connectionStorage: connectionStorage + connectionStorage: self.connectionStorage ) } From 5fc896cfceb8b3e17f2bc031cc4c6c0f72936f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 15:40:08 +0700 Subject: [PATCH 26/28] test(sidebar): cover FavoriteSelection round-trip and scope table selection by database --- .../FavoritesSidebarViewModel.swift | 14 +++--- TablePro/Views/Sidebar/FavoritesTabView.swift | 19 ++++---- .../ViewModels/FavoriteSelectionTests.swift | 44 +++++++++++++++++++ 3 files changed, 63 insertions(+), 14 deletions(-) create mode 100644 TableProTests/ViewModels/FavoriteSelectionTests.swift diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel.swift b/TablePro/ViewModels/FavoritesSidebarViewModel.swift index df120eec6..cb2e9ddc7 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel.swift @@ -14,7 +14,7 @@ internal struct FavoriteEditItem: Identifiable { } internal enum FavoriteSelection: Hashable { - case table(schema: String?, name: String) + case table(database: String?, schema: String?, name: String) case node(id: String) } @@ -24,8 +24,12 @@ extension FavoriteSelection: RawRepresentable { init?(rawValue: String) { let parts = rawValue.components(separatedBy: Self.separator) switch parts.first { - case "table" where parts.count == 3: - self = .table(schema: parts[1].isEmpty ? nil : parts[1], name: parts[2]) + 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: @@ -35,8 +39,8 @@ extension FavoriteSelection: RawRepresentable { var rawValue: String { switch self { - case .table(let schema, let name): - return ["table", schema ?? "", name].joined(separator: Self.separator) + 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) } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 16cd148de..f878742b3 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -206,7 +206,7 @@ internal struct FavoritesTabView: View { Image(systemName: TableRowLogic.iconName(for: table.type)) .foregroundStyle(TableRowLogic.iconColor(table: table, isPendingDelete: false, isPendingTruncate: false)) } - .tag(FavoriteSelection.table(schema: table.schema, name: table.name)) + .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) .accessibilityLabel( TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) ) @@ -231,15 +231,16 @@ internal struct FavoritesTabView: View { } } - private func favoriteTable(schema: String?, name: String) -> TableInfo? { - availableFavoriteTables.first { $0.name == name && $0.schema == schema } + 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 contextMenu(for selection: FavoriteSelection) -> some View { switch selection { - case .table(let schema, let name): - if let table = favoriteTable(schema: schema, name: name) { + case .table(let database, let schema, let name): + if let table = favoriteTable(database: database, schema: schema, name: name) { favoriteTableContextMenu(table) } case .node(let id): @@ -262,8 +263,8 @@ internal struct FavoritesTabView: View { private func handlePrimaryAction(_ selection: FavoriteSelection) { switch selection { - case .table(let schema, let name): - if let table = favoriteTable(schema: schema, name: name) { + 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): @@ -282,8 +283,8 @@ internal struct FavoritesTabView: View { private func deleteSelectedNode() { guard let selection = sidebarState.selectedFavorite else { return } switch selection { - case .table(let schema, let name): - if let table = favoriteTable(schema: schema, name: name) { + 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 ) 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) + } +} From 3f9ffba0173ff00dfb0abbae8a8014fd5b765978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= Date: Fri, 29 May 2026 16:59:10 +0700 Subject: [PATCH 27/28] feat(sidebar): restore recent tables section --- CHANGELOG.md | 1 + TablePro/Core/Storage/RecentTablesStore.swift | 68 +++++++++++++ TablePro/ViewModels/SidebarViewModel.swift | 1 + .../MainContentCoordinator+Navigation.swift | 5 + TablePro/Views/Sidebar/SidebarView.swift | 63 ++++++++++++ .../Storage/RecentTablesStoreTests.swift | 95 +++++++++++++++++++ docs/features/favorites.mdx | 6 +- 7 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 TablePro/Core/Storage/RecentTablesStore.swift create mode 100644 TableProTests/Storage/RecentTablesStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9beb700..e0ed401c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. (#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 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/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/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 813158eff..1f248411d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,6 +21,11 @@ extension MainContentCoordinator { redirectToSibling: Bool = false, forceNonPreview: Bool = false ) { + RecentTablesStore.shared.push( + connectionID: connection.id, + database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, + table: table + ) openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index b97a8184b..cd0da0a3b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -12,6 +12,7 @@ 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] = [] var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -261,11 +262,24 @@ 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, @@ -284,8 +298,53 @@ struct SidebarView: View { ) } + private func reloadRecentTables() { + recentTables = RecentTablesStore.shared.entries( + connectionID: connectionId, + database: activeDatabase + ) + } + + @ViewBuilder + private var recentSection: some View { + let recents = filteredRecents + if !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) } @@ -322,8 +381,12 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) } + .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in + reloadRecentTables() + } .onAppear { favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + reloadRecentTables() } } 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/docs/features/favorites.mdx b/docs/features/favorites.mdx index c6ddb2688..83b4df576 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -5,7 +5,7 @@ description: Mark tables as favorites and save frequently used queries with opti # Favorites -The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. +The Tables sidebar shows a **Recent** section at the top with the last 10 tables you opened in the current connection and database. The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. ## Table Favorites @@ -18,6 +18,10 @@ Double-click a table in the Favorites tab to open it. Right-click it to open the 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 + +Each table you open is added to the **Recent** section at the top of the Tables sidebar. The list 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. From 908a15bccdbf9255b13d4889088defe1d3ef0b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= Date: Fri, 29 May 2026 17:08:44 +0700 Subject: [PATCH 28/28] feat(settings): add toggle for the sidebar recent tables section --- CHANGELOG.md | 2 +- .../Models/Settings/GeneralSettings.swift | 11 ++++++-- .../MainContentCoordinator+Navigation.swift | 12 ++++---- .../Views/Settings/GeneralSettingsView.swift | 5 ++++ TablePro/Views/Sidebar/SidebarView.swift | 10 ++++++- .../Models/GeneralSettingsTests.swift | 28 +++++++++++++++++++ docs/customization/settings.mdx | 6 ++++ docs/features/favorites.mdx | 4 +-- 8 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 TableProTests/Models/GeneralSettingsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ed401c1..30ada05a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. (#1352) +- 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 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/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 1f248411d..3ca514e38 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -21,11 +21,13 @@ extension MainContentCoordinator { redirectToSibling: Bool = false, forceNonPreview: Bool = false ) { - RecentTablesStore.shared.push( - connectionID: connection.id, - database: activeDatabaseName.isEmpty ? nil : activeDatabaseName, - table: table - ) + 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/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cd0da0a3b..73f5d8736 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -13,6 +13,7 @@ struct SidebarView: View { @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 @@ -299,6 +300,10 @@ struct SidebarView: View { } private func reloadRecentTables() { + guard settingsManager.general.showRecentTables else { + recentTables = [] + return + } recentTables = RecentTablesStore.shared.entries( connectionID: connectionId, database: activeDatabase @@ -308,7 +313,7 @@ struct SidebarView: View { @ViewBuilder private var recentSection: some View { let recents = filteredRecents - if !recents.isEmpty { + if settingsManager.general.showRecentTables, !recents.isEmpty { Section(isExpanded: $viewModel.isRecentsExpanded) { ForEach(recents) { entry in let info = tableInfo(forRecent: entry) @@ -384,6 +389,9 @@ struct SidebarView: View { .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in reloadRecentTables() } + .onChange(of: settingsManager.general.showRecentTables) { _, _ in + reloadRecentTables() + } .onAppear { favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) reloadRecentTables() 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/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/features/favorites.mdx b/docs/features/favorites.mdx index 83b4df576..4776e8ea7 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -5,7 +5,7 @@ description: Mark tables as favorites and save frequently used queries with opti # Favorites -The Tables sidebar shows a **Recent** section at the top with the last 10 tables you opened in the current connection and database. The Favorites tab has two sections: **Tables** for pinned tables and **Queries** for saved SQL. +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 @@ -20,7 +20,7 @@ Favorites are scoped to the connection, database, and schema, and sync through i ## Recent Tables -Each table you open is added to the **Recent** section at the top of the Tables sidebar. The list 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. +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