From df0c81632354e5729f23f61896f7871c779f0219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 18:27:33 +0700 Subject: [PATCH 1/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2c9051166..9c535254a 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ From e160923fffe52407f85ace56927cbc7896487e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 19:25:09 +0700 Subject: [PATCH 2/7] Update CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging From dbddd3878e93bc3a5dafa3bdc0e196fbaed62548 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 3/7] 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 bf06ab8ac..9a3ffb830 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 7a68c1b82932207e9ee0d58f2c8732bd2437da23 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 4/7] 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 9a3ffb830..0869c0471 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) @@ -21,6 +23,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 40ca11e9c..843a47a10 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -157,12 +157,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 @@ -187,11 +193,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() { @@ -304,7 +312,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()) @@ -452,11 +461,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?) { @@ -483,6 +494,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 a00a21042090026605dc7d46bf7d51e5de54a3e2 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 5/7] feat(sidebar): handle tableFavorite in conflict resolution, fix showERDiagram call --- TablePro/Resources/Localizable.xcstrings | 108 ++++++++++++++++++ .../Components/ConflictResolutionView.swift | 2 +- TablePro/Views/Sidebar/FavoritesTabView.swift | 2 +- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 28500ba9d..106559871 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1719,6 +1719,18 @@ }, "%d selected" : { + }, + "%d-%d of ? rows" : { + "comment" : "A description of the number of rows currently displayed in a table view, including a range of visible rows and a placeholder for the total number of rows. The first argument is the starting index of the visible rows. The second argument is the ending index of the visible rows.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$d-%2$d of ? rows" + } + } + } }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -1929,6 +1941,7 @@ } }, "%lld of %lld" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4726,6 +4739,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" : { @@ -5368,6 +5385,10 @@ } } }, + "All rows…" : { + "comment" : "A button that allows the user to view all rows.", + "isCommentAutoGenerated" : true + }, "ALL SCHEMAS" : { "extractionState" : "stale", "localizations" : { @@ -10164,6 +10185,10 @@ }, "cloudflared was not found. Install it with `brew install cloudflared`, or set its path in the connection's Cloudflare Tunnel settings." : { + }, + "cloudflared was not found. Set its path below first." : { + "comment" : "Error message displayed when the `cloudflared` binary is not found and the user attempts to sign in using the browser.", + "isCommentAutoGenerated" : true }, "CMD" : { "extractionState" : "stale", @@ -13321,7 +13346,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" : { @@ -13987,6 +14017,10 @@ }, "Custom Slash Commands" : { + }, + "Custom…" : { + "comment" : "A button that opens a sheet to set the page size manually.", + "isCommentAutoGenerated" : true }, "Customization" : { @@ -21328,6 +21362,10 @@ } } }, + "favorite" : { + "comment" : "A label indicating that a table is marked as a favorite.", + "isCommentAutoGenerated" : true + }, "Favorites" : { "localizations" : { "tr" : { @@ -22115,6 +22153,14 @@ }, "First column" : { + }, + "First page" : { + "comment" : "A navigation button that takes the user to the first page of a list.", + "isCommentAutoGenerated" : true + }, + "First Page" : { + "comment" : "Name of the keyboard shortcut for moving to the first page of a paginated view.", + "isCommentAutoGenerated" : true }, "Fit to Window" : { "localizations" : { @@ -22401,6 +22447,7 @@ } }, "Format JSON" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -23023,6 +23070,9 @@ } } } + }, + "Go to page" : { + }, "Go to Settings…" : { "localizations" : { @@ -26626,6 +26676,14 @@ }, "Last Activity" : { + }, + "Last page" : { + "comment" : "A navigation button that takes the user to the last page.", + "isCommentAutoGenerated" : true + }, + "Last Page" : { + "comment" : "Name of the keyboard shortcut action that takes the user to the last page of a paginated view.", + "isCommentAutoGenerated" : true }, "Last query execution summary" : { "localizations" : { @@ -27229,6 +27287,7 @@ } }, "Limit" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30114,6 +30173,10 @@ } } }, + "Next page" : { + "comment" : "A button that navigates to the next page of data.", + "isCommentAutoGenerated" : true + }, "Next Page" : { "localizations" : { "tr" : { @@ -30137,6 +30200,7 @@ } }, "Next Page (⌘])" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -32566,6 +32630,7 @@ } }, "Offset" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33240,6 +33305,10 @@ } } }, + "Open Table" : { + "comment" : "A context menu option to open a table in the main view.", + "isCommentAutoGenerated" : true + }, "Open Table Tab" : { }, @@ -33711,6 +33780,10 @@ } } }, + "Page number" : { + "comment" : "A label for the text field in the \"Go to page\" popover.", + "isCommentAutoGenerated" : true + }, "Page Size" : { "localizations" : { "tr" : { @@ -33806,6 +33879,7 @@ } }, "Pagination Settings" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -35801,6 +35875,10 @@ } } }, + "Previous page" : { + "comment" : "A button label for moving to the previous page.", + "isCommentAutoGenerated" : true + }, "Previous Page" : { "localizations" : { "tr" : { @@ -35824,6 +35902,7 @@ } }, "Previous Page (⌘[)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -36488,6 +36567,10 @@ } } }, + "Queries" : { + "comment" : "A section header for the list of queries in the favorites tab.", + "isCommentAutoGenerated" : true + }, "Query" : { "localizations" : { "tr" : { @@ -38360,6 +38443,10 @@ } } }, + "Remove from Favorites" : { + "comment" : "A button label that deletes a table from the user's favorites.", + "isCommentAutoGenerated" : true + }, "Remove from Group" : { "localizations" : { "tr" : { @@ -39859,6 +39946,10 @@ } } }, + "Rows per page" : { + "comment" : "A picker to select the number of rows to display per page.", + "isCommentAutoGenerated" : true + }, "Rules" : { }, @@ -42693,6 +42784,10 @@ } } }, + "Show All Rows" : { + "comment" : "Title of the alert that appears when the user wants to show all rows.", + "isCommentAutoGenerated" : true + }, "Show All Tables" : { "extractionState" : "stale", "localizations" : { @@ -44562,6 +44657,10 @@ } } }, + "SSH tunnel disconnected. Click to reconnect." : { + "comment" : "Message shown when an SSH tunnel is disconnected.", + "isCommentAutoGenerated" : true + }, "SSH tunneling and SSL/TLS encryption support" : { "localizations" : { "tr" : { @@ -46205,6 +46304,7 @@ } }, "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46226,6 +46326,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" : { @@ -48468,6 +48572,10 @@ } } }, + "This will load all %@ rows on a single page. Large result sets use significant memory. Continue?" : { + "comment" : "Text displayed in a confirmation dialog when the user is asked whether to load all rows in a single page. The argument is the number of rows, formatted with commas.", + "isCommentAutoGenerated" : true + }, "This will permanently delete %lld %@. This action cannot be undone." : { "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 906cc2324a4fde10a3a9048edab2de2c60e6c340 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 6/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c535254a..c3d1be70c 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,7 @@ Thumbs.db *.p12 *.mobileprovision Secrets.xcconfig +Local.xcconfig # Debug *.log From d8b7166dfa6a39af918fe3b0a89cfc4be03eae0e 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 7/7] 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"]) - } -}