diff --git a/.gitignore b/.gitignore index 2c9051166..9c6ddea64 100644 --- a/.gitignore +++ b/.gitignore @@ -125,6 +125,7 @@ Thumbs.db *.p12 *.mobileprovision Secrets.xcconfig +Local.xcconfig # Debug *.log @@ -154,3 +155,5 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ +Local.xcconfig diff --git a/CHANGELOG.md b/CHANGELOG.md index 898c83693..b7e024f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ 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. +- 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 +24,16 @@ 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. +- "View ER Diagram" from the sidebar right-click menu. Access it from the Favorites tab context menu 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/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 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"> + + + + 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/FavoriteTablesStorage.swift b/TablePro/Core/Storage/FavoriteTablesStorage.swift new file mode 100644 index 000000000..37281b6f2 --- /dev/null +++ b/TablePro/Core/Storage/FavoriteTablesStorage.swift @@ -0,0 +1,132 @@ + +import Foundation +import os + +extension Notification.Name { + static let favoriteTablesDidChange = Notification.Name("FavoriteTablesDidChange") +} + +final class FavoriteTablesStorage { + static let shared = FavoriteTablesStorage() + private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteTablesStorage") + + struct FavoriteEntry: Codable, Hashable { + let connectionId: UUID + let schema: String? + let name: String + } + + private let defaults: UserDefaults + private let syncTracker: SyncChangeTracker + private let key = "com.TablePro.favoriteTables" + private var cache: Set? + private let lock = NSLock() + + init(userDefaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) { + self.defaults = userDefaults + self.syncTracker = syncTracker + } + + func loadFavorites() -> Set { + lock.lock() + defer { lock.unlock() } + return _loadFavorites() + } + + func favorites(for connectionId: UUID) -> Set { + lock.lock() + defer { lock.unlock() } + return _loadFavorites().filter { $0.connectionId == connectionId } + } + + func isFavorite(name: String, schema: String?, connectionId: UUID) -> Bool { + lock.lock() + defer { lock.unlock() } + return _loadFavorites().contains(FavoriteEntry(connectionId: connectionId, schema: schema, name: name)) + } + + func toggle(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + let isPresent = favorites.contains(entry) + lock.unlock() + + if isPresent { + removeFavorite(name: name, schema: schema, connectionId: connectionId) + } else { + addFavorite(name: name, schema: schema, connectionId: connectionId) + } + } + + func addFavorite(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + guard favorites.insert(entry).inserted else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: entry)) + } + + func addFavoriteWithoutSync(_ entry: FavoriteEntry) { + lock.lock() + var favorites = _loadFavorites() + guard favorites.insert(entry).inserted else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + } + + func removeFavorite(name: String, schema: String?, connectionId: UUID) { + let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + lock.lock() + var favorites = _loadFavorites() + guard favorites.remove(entry) != nil else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry)) + } + + func removeFavoriteWithoutSync(_ entry: FavoriteEntry) { + lock.lock() + var favorites = _loadFavorites() + guard favorites.remove(entry) != nil else { lock.unlock(); return } + _persist(favorites) + lock.unlock() + } + + func removeFavoriteWithoutSync(id: String) { + lock.lock() + var favorites = _loadFavorites() + guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { lock.unlock(); return } + favorites.remove(entry) + _persist(favorites) + lock.unlock() + } + + static func syncId(for entry: FavoriteEntry) -> String { + let raw = entry.connectionId.uuidString + "|" + (entry.schema ?? "") + "|" + entry.name + return raw.sha256 + } + + private func _loadFavorites() -> Set { + if let cache { return cache } + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode(Set.self, from: data) else { + cache = [] + return [] + } + cache = decoded + return decoded + } + + private func _persist(_ favorites: Set) { + cache = favorites + guard let data = try? JSONEncoder().encode(favorites) else { + Self.logger.error("Failed to encode favorite tables") + return + } + defaults.set(data, forKey: key) + NotificationCenter.default.post(name: .favoriteTablesDidChange, object: nil) + } +} diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift new file mode 100644 index 000000000..431b1727c --- /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/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index fa0fd3577..280ef16bd 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -167,12 +167,25 @@ final class SyncCoordinator { changeTracker.markDirty(.sshProfile, id: profile.id.uuidString) } + let favoriteTables = services.favoriteTablesStorage.loadFavorites() + for entry in favoriteTables { + changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: entry)) + } + // Mark all settings categories as dirty for category in ["general", "appearance", "editor", "dataGrid", "history", "tabs", "keyboard", "ai"] { changeTracker.markDirty(.settings, id: category) } - Self.logger.info("Marked all local data dirty: \(connections.count) connections, \(groups.count) groups, \(tags.count) tags, \(sshProfiles.count) SSH profiles, 8 settings categories") + let summary = [ + "connections=\(connections.count)", + "groups=\(groups.count)", + "tags=\(tags.count)", + "sshProfiles=\(sshProfiles.count)", + "favoriteTables=\(favoriteTables.count)", + "settings=8" + ].joined(separator: ", ") + Self.logger.info("Marked all local data dirty: \(summary, privacy: .public)") } /// Called when user disables sync in settings @@ -291,6 +304,10 @@ final class SyncCoordinator { } } + if settings.syncTableFavorites { + collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) + } + // Deduplicate deletion IDs to prevent CloudKit "can't delete same record twice" error let uniqueDeletions = Array(Set(recordIDsToDelete)) @@ -312,6 +329,9 @@ final class SyncCoordinator { if settings.syncSettings { changeTracker.clearAllDirty(.settings) } + if settings.syncTableFavorites { + changeTracker.clearAllDirty(.tableFavorite) + } // Clear tombstones only for types that were actually pushed if settings.syncConnections { @@ -337,6 +357,11 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) } } + if settings.syncTableFavorites { + for tombstone in metadataStorage.tombstones(for: .tableFavorite) { + metadataStorage.removeTombstone(type: .tableFavorite, id: tombstone.id) + } + } Self.logger.info("Push completed: \(recordsToSave.count) saved, \(recordIDsToDelete.count) deleted") } catch let error as CKError where error.code == .serverRecordChanged { @@ -403,6 +428,7 @@ final class SyncCoordinator { let groupTombstoneIds = Set(metadataStorage.tombstones(for: .group).map(\.id)) let tagTombstoneIds = Set(metadataStorage.tombstones(for: .tag).map(\.id)) let sshTombstoneIds = Set(metadataStorage.tombstones(for: .sshProfile).map(\.id)) + let tableFavoriteTombstoneIds = Set(metadataStorage.tombstones(for: .tableFavorite).map(\.id)) for record in result.changedRecords { switch record.recordType { @@ -422,6 +448,8 @@ final class SyncCoordinator { applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) + case SyncRecordType.tableFavorite.rawValue where settings.syncTableFavorites: + applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds) default: break } @@ -431,6 +459,7 @@ final class SyncCoordinator { var groupIdsToDelete: Set = [] var tagIdsToDelete: Set = [] var sshProfileIdsToDelete: Set = [] + var tableFavoriteIdsToDelete: Set = [] for recordID in result.deletedRecordIDs { let name = recordID.recordName @@ -449,6 +478,8 @@ final class SyncCoordinator { } else if name.hasPrefix("SSHProfile_"), let uuid = UUID(uuidString: String(name.dropFirst("SSHProfile_".count))) { sshProfileIdsToDelete.insert(uuid) + } else if name.hasPrefix("FavoriteTable_") { + tableFavoriteIdsToDelete.insert(String(name.dropFirst("FavoriteTable_".count))) } } @@ -474,6 +505,9 @@ final class SyncCoordinator { profiles.removeAll { sshProfileIdsToDelete.contains($0.id) } services.sshProfileStorage.saveProfilesWithoutSync(profiles) } + for id in tableFavoriteIdsToDelete { + services.favoriteTablesStorage.removeFavoriteWithoutSync(id: id) + } if actualConnectionChanges || groupsOrTagsChanged { services.appEvents.connectionUpdated.send(nil) @@ -584,10 +618,33 @@ final class SyncCoordinator { do { try applySettingsData(data, for: category) } catch { - Self.logger.error("Skipping remote settings \(record.recordID.recordName, privacy: .public) (\(category, privacy: .public)): \(error.localizedDescription, privacy: .public)") + let recordName = record.recordID.recordName + let message = error.localizedDescription + Self.logger.error( + "Skipping remote settings \(recordName, privacy: .public) (\(category, privacy: .public)): \(message, privacy: .public)" + ) } } + @discardableResult + private func applyRemoteTableFavorite(_ record: CKRecord, tombstoneIds: Set) -> Bool { + let entry: FavoriteTablesStorage.FavoriteEntry + do { + entry = try SyncRecordMapper.favoriteEntry(from: record) + } catch { + let recordName = record.recordID.recordName + let message = error.localizedDescription + Self.logger.error( + "Skipping remote favorite table \(recordName, privacy: .public): \(message, privacy: .public)" + ) + return false + } + if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: entry)) { return false } + let before = services.favoriteTablesStorage.loadFavorites() + services.favoriteTablesStorage.addFavoriteWithoutSync(entry) + return before != services.favoriteTablesStorage.loadFavorites() + } + // MARK: - Observers private func observeAccountChanges() { @@ -688,6 +745,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 +884,24 @@ final class SyncCoordinator { ) } } + + private func collectDirtyTableFavorites( + into records: inout [CKRecord], + deletions: inout [CKRecord.ID], + zoneID: CKRecordZone.ID + ) { + let dirtyIds = changeTracker.dirtyRecords(for: .tableFavorite) + if !dirtyIds.isEmpty { + let favorites = services.favoriteTablesStorage.loadFavorites() + for entry in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: entry)) { + records.append(SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID)) + } + } + + for tombstone in metadataStorage.tombstones(for: .tableFavorite) { + deletions.append( + SyncRecordMapper.recordID(type: .tableFavorite, id: tombstone.id, in: zoneID) + ) + } + } } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index b0e236b1c..83e375dbe 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,37 @@ struct SyncRecordMapper { record["settingsJson"] as? Data } + // MARK: - Table Favorite + + static func toCKRecord(favoriteEntry entry: FavoriteTablesStorage.FavoriteEntry, in zone: CKRecordZone.ID) -> CKRecord { + let favoriteId = FavoriteTablesStorage.syncId(for: entry) + let recordID = recordID(type: .tableFavorite, id: favoriteId, in: zone) + let record = CKRecord(recordType: SyncRecordType.tableFavorite.rawValue, recordID: recordID) + + record["favoriteTableId"] = favoriteId as CKRecordValue + record["connectionId"] = entry.connectionId.uuidString as CKRecordValue + record["name"] = entry.name as CKRecordValue + if let schema = entry.schema { + record["schema"] = schema as CKRecordValue + } + record["modifiedAtLocal"] = Date() as CKRecordValue + record["schemaVersion"] = schemaVersion as CKRecordValue + + return record + } + + static func favoriteEntry(from record: CKRecord) throws -> FavoriteTablesStorage.FavoriteEntry { + guard let name = record["name"] as? String, !name.isEmpty else { + throw SyncDecodeError.missingRequiredField("name") + } + guard let connectionIdString = record["connectionId"] as? String, + let connectionId = UUID(uuidString: connectionIdString) else { + throw SyncDecodeError.missingRequiredField("connectionId") + } + let schema = record["schema"] as? String + return FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: schema, name: name) + } + // MARK: - SSH Profile static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index 41e9228ca..f90b0841c 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -15,6 +15,7 @@ struct SyncSettings: Codable, Equatable { var syncSettings: Bool var syncPasswords: Bool var syncSSHProfiles: Bool + var syncTableFavorites: Bool init( enabled: Bool, @@ -22,7 +23,8 @@ struct SyncSettings: Codable, Equatable { syncGroupsAndTags: Bool, syncSettings: Bool, syncPasswords: Bool = false, - syncSSHProfiles: Bool = true + syncSSHProfiles: Bool = true, + syncTableFavorites: Bool = true ) { self.enabled = enabled self.syncConnections = syncConnections @@ -30,6 +32,7 @@ struct SyncSettings: Codable, Equatable { self.syncSettings = syncSettings self.syncPasswords = syncPasswords self.syncSSHProfiles = syncSSHProfiles + self.syncTableFavorites = syncTableFavorites } init(from decoder: Decoder) throws { @@ -40,6 +43,7 @@ struct SyncSettings: Codable, Equatable { syncSettings = try container.decode(Bool.self, forKey: .syncSettings) syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true + syncTableFavorites = try container.decodeIfPresent(Bool.self, forKey: .syncTableFavorites) ?? true } static let `default` = SyncSettings( @@ -48,6 +52,7 @@ struct SyncSettings: Codable, Equatable { syncGroupsAndTags: true, syncSettings: true, syncPasswords: false, - syncSSHProfiles: true + syncSSHProfiles: true, + syncTableFavorites: true ) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index d9f25edc0..99a3bda2e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -4737,6 +4737,10 @@ } } }, + "Add to Favorites" : { + "comment" : "A label that describes an action to add an item to a user's favorites.", + "isCommentAutoGenerated" : true + }, "Add validation rules to ensure data integrity" : { "localizations" : { "tr" : { @@ -13351,7 +13355,12 @@ } } }, + "Create New Table" : { + "comment" : "Tooltip and accessibility label for the button that allows the user to create a new table.", + "isCommentAutoGenerated" : true + }, "Create New Table..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21361,6 +21370,10 @@ } } }, + "favorite" : { + "comment" : "A label indicating that a table is marked as a favorite.", + "isCommentAutoGenerated" : true + }, "Favorites" : { "localizations" : { "tr" : { @@ -33313,6 +33326,10 @@ } } }, + "Open Table" : { + "comment" : "A context menu option to open a table in the main view.", + "isCommentAutoGenerated" : true + }, "Open Table Tab" : { }, @@ -36584,6 +36601,10 @@ } } }, + "Queries" : { + "comment" : "A section header for the list of queries in the favorites tab.", + "isCommentAutoGenerated" : true + }, "Query" : { "localizations" : { "tr" : { @@ -38456,6 +38477,10 @@ } } }, + "Remove from Favorites" : { + "comment" : "A button label that deletes a table from the user's favorites.", + "isCommentAutoGenerated" : true + }, "Remove from Group" : { "localizations" : { "tr" : { @@ -46310,6 +46335,7 @@ } }, "Syncs connections, settings, and SSH profiles across your Macs via iCloud." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -46331,6 +46357,10 @@ } } }, + "Syncs connections, table favorites, settings, and SSH profiles across your Macs via iCloud." : { + "comment" : "A description of the functionality of the \"iCloud Sync\" toggle.", + "isCommentAutoGenerated" : true + }, "Syncs passwords via iCloud Keychain (end-to-end encrypted)." : { "localizations" : { "en" : { diff --git a/TablePro/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/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/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 813158eff..4cd8b9d74 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.nilIfEmpty, + table: table + ) openTableTab( table.name, schema: table.schema, diff --git a/TablePro/Views/Settings/Sections/SyncSection.swift b/TablePro/Views/Settings/Sections/SyncSection.swift index ea9032aed..79201d7e0 100644 --- a/TablePro/Views/Settings/Sections/SyncSection.swift +++ b/TablePro/Views/Settings/Sections/SyncSection.swift @@ -24,7 +24,7 @@ struct SyncSection: View { syncCoordinator.disableSync() } } - .help("Syncs connections, settings, and SSH profiles across your Macs via iCloud.") + .help("Syncs connections, table favorites, settings, and SSH profiles across your Macs via iCloud.") .disabled(!isProAvailable) } header: { HStack(spacing: 6) { @@ -120,6 +120,7 @@ struct SyncSection: View { Toggle("Groups & Tags:", isOn: $settingsManager.sync.syncGroupsAndTags) Toggle("SSH Profiles:", isOn: $settingsManager.sync.syncSSHProfiles) Toggle("Settings:", isOn: $settingsManager.sync.syncSettings) + Toggle("Table Favorites:", isOn: $settingsManager.sync.syncTableFavorites) } } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 30e9fc5ba..75f9ac712 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: [FavoriteTablesStorage.FavoriteEntry] = [] @State private var folderToDelete: SQLFavoriteFolder? @State private var showDeleteFolderAlert = false @State private var linkedFileToTrash: LinkedSQLFavorite? @@ -17,14 +18,21 @@ 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] { + favoriteTables.compactMap { entry in + tables.first { $0.name == entry.name && $0.schema == entry.schema } + } + } - 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 +41,19 @@ internal struct FavoritesTabView: View { var body: some View { Group { let items = viewModel.filteredNodes(searchText: searchText) + let filteredTables = searchText.isEmpty + ? availableFavoriteTables + : availableFavoriteTables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty { + if !viewModel.isInitialLoadComplete && viewModel.nodes.isEmpty && filteredTables.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.nodes.isEmpty && searchText.isEmpty { + } else if viewModel.nodes.isEmpty && filteredTables.isEmpty && searchText.isEmpty { emptyState - } else if items.isEmpty { + } else if items.isEmpty && filteredTables.isEmpty { noMatchState } else { - favoritesList(items) + favoritesList(items, filteredTables: filteredTables) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -53,6 +64,10 @@ internal struct FavoritesTabView: View { } .onAppear { SQLFolderWatcher.shared.start() + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } + } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name } } .sheet(item: $viewModel.editDialogItem) { item in FavoriteEditDialog( @@ -133,9 +148,23 @@ 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) + } + } } .listStyle(.sidebar) .scrollContentBackground(.hidden) @@ -152,9 +181,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() + } + + Divider() + + Button(role: .destructive) { + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) + } 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 +241,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 +254,22 @@ internal struct FavoritesTabView: View { } } + private func deleteSelectedNode() { + guard let nodeId = sidebarState.selectedFavoriteNodeId else { return } + if let table = favoriteTable(forNodeId: nodeId) { + FavoriteTablesStorage.shared.removeFavorite(name: table.name, schema: table.schema, connectionId: connectionId) + 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 +354,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..f2e8e3b5f 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() } @@ -100,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: ",")) } @@ -126,9 +128,14 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } - if let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty, hasSelection { + let maintenanceOps = coordinator?.supportedMaintenanceOperations() ?? [] + if SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: isReadOnly, + hasSelection: hasSelection, + supportedOperations: maintenanceOps + ) { Menu(String(localized: "Maintenance")) { - ForEach(ops, id: \.self) { op in + ForEach(maintenanceOps, id: \.self) { op in Button(op) { if let table = clickedTable?.name { coordinator?.showMaintenanceSheet(operation: op, tableName: table) @@ -136,7 +143,6 @@ struct SidebarContextMenu: View { } } } - .disabled(isReadOnly) } Divider() diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index cc072cdbf..033135a4c 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -11,6 +11,8 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel @Bindable private var schemaService = SchemaService.shared + @State private var favoriteTables: Set = [] + @State private var recentTables: [RecentTablesStore.Entry] = [] var sidebarState: SharedSidebarState var windowState: WindowSidebarState @@ -110,6 +112,7 @@ struct SidebarView: View { FavoritesTabView( connectionId: connectionId, windowState: coordinator.windowSidebarState, + tables: tables, coordinator: coordinator ) } else { @@ -232,8 +235,63 @@ 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() { + let database = coordinator?.activeDatabaseName.nilIfEmpty + 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(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: info.schema, name: info.name)), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: info.name, schema: info.schema, connectionId: connectionId) } + ) + .tag(info) + .contextMenu { + SidebarContextMenu( + clickedTable: info, + selectedTables: windowState.selectedTables, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + } + } header: { + Text(String(localized: "Recent")) + } + } + } + private var tableList: some View { List(selection: selectedTablesBinding) { + recentSection + ForEach(SidebarObjectKind.allCases, id: \.self) { kind in sectionView(for: kind) } @@ -267,6 +325,16 @@ struct SidebarView: View { .onExitCommand { windowState.selectedTables.removeAll() } + .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + } + .onReceive(NotificationCenter.default.publisher(for: .recentTablesDidChange)) { _ in + reloadRecentTables() + } + .onAppear { + favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + reloadRecentTables() + } } // MARK: - Section View @@ -308,7 +376,9 @@ struct SidebarView: View { TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + isPendingDelete: pendingDeletes.contains(table.name), + isFavorite: favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: table.schema, name: table.name)), + onToggleFavorite: { FavoriteTablesStorage.shared.toggle(name: table.name, schema: table.schema, connectionId: connectionId) } ) .tag(table) .contextMenu { diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 6c23ef681..4607d183b 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,8 @@ struct TableRow: View { let table: TableInfo 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) @@ -70,32 +74,60 @@ 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) + 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) - .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..636609e33 --- /dev/null +++ b/TableProTests/Core/Storage/FavoriteTablesStorageTests.swift @@ -0,0 +1,99 @@ + +import Foundation +@testable import TablePro +import Testing + +@Suite("FavoriteTablesStorage") +struct FavoriteTablesStorageTests { + private func makeStorage() throws -> (FavoriteTablesStorage, SyncMetadataStorage) { + let favoritesSuite = "FavoriteTablesStorageTests.favorites.\(UUID().uuidString)" + let syncSuite = "FavoriteTablesStorageTests.sync.\(UUID().uuidString)" + let favoritesDefaults = try #require(UserDefaults(suiteName: favoritesSuite)) + let syncDefaults = try #require(UserDefaults(suiteName: syncSuite)) + favoritesDefaults.removePersistentDomain(forName: favoritesSuite) + syncDefaults.removePersistentDomain(forName: syncSuite) + + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + let storage = FavoriteTablesStorage(userDefaults: favoritesDefaults, syncTracker: tracker) + return (storage, metadata) + } + + @Test("Add favorite marks stable sync ID dirty") + func addMarksDirty() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, connectionId: connId) + + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "users") + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(storage.loadFavorites() == [entry]) + #expect(metadata.dirtyIds(for: .tableFavorite) == [id]) + } + + @Test("Remove favorite creates sync tombstone") + func removeCreatesTombstone() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: nil, connectionId: connId) + storage.removeFavorite(name: "users", schema: nil, connectionId: connId) + + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "users") + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(storage.loadFavorites().isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + #expect(metadata.tombstones(for: .tableFavorite).contains { $0.id == id }) + } + + @Test("Remote apply helpers do not track local sync changes") + func withoutSyncDoesNotTrackChanges() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "orders") + storage.addFavoriteWithoutSync(entry) + storage.removeFavoriteWithoutSync(entry) + + #expect(storage.loadFavorites().isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + #expect(metadata.tombstones(for: .tableFavorite).isEmpty) + } + + @Test("Favorites scoped per connection: same name in different connections are distinct") + func favoritesAreConnectionScoped() throws { + let (storage, _) = try makeStorage() + let connA = UUID() + let connB = UUID() + storage.addFavorite(name: "users", schema: nil, connectionId: connA) + storage.addFavorite(name: "users", schema: nil, connectionId: connB) + + let favA = storage.favorites(for: connA) + let favB = storage.favorites(for: connB) + #expect(favA.count == 1) + #expect(favB.count == 1) + #expect(favA.first?.connectionId == connA) + #expect(favB.first?.connectionId == connB) + #expect(storage.loadFavorites().count == 2) + } + + @Test("Schema-qualified and unqualified same-named tables are distinct") + func schemaQualifiedIsDistinct() throws { + let (storage, _) = try makeStorage() + let connId = UUID() + storage.addFavorite(name: "users", schema: "public", connectionId: connId) + storage.addFavorite(name: "users", schema: "app", connectionId: connId) + storage.addFavorite(name: "users", schema: nil, connectionId: connId) + + #expect(storage.favorites(for: connId).count == 3) + } + + @Test("Toggle on then off leaves no dirty entries") + func toggleOnThenOffNoDirty() throws { + let (storage, metadata) = try makeStorage() + let connId = UUID() + storage.toggle(name: "orders", schema: nil, connectionId: connId) + storage.toggle(name: "orders", schema: nil, connectionId: connId) + + #expect(storage.favorites(for: connId).isEmpty) + #expect(metadata.dirtyIds(for: .tableFavorite).isEmpty) + } +} diff --git a/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift new file mode 100644 index 000000000..301a7c179 --- /dev/null +++ b/TableProTests/Core/Sync/SyncRecordMapperFavoriteTableTests.swift @@ -0,0 +1,48 @@ + +import CloudKit +import Foundation +@testable import TablePro +import Testing + +@Suite("SyncRecordMapper favorite tables") +struct SyncRecordMapperFavoriteTableTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + + @Test("Table favorite record round trips all fields") + func tableFavoriteRoundTrip() throws { + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: "public", name: "users") + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) + + let id = FavoriteTablesStorage.syncId(for: entry) + #expect(record.recordType == SyncRecordType.tableFavorite.rawValue) + #expect(record.recordID.recordName == "FavoriteTable_\(id)") + #expect(record["favoriteTableId"] as? String == id) + #expect(record["name"] as? String == "users") + #expect(record["connectionId"] as? String == connId.uuidString) + #expect(record["schema"] as? String == "public") + + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @Test("Table favorite without schema round trips correctly") + func tableFavoriteNoSchemaRoundTrip() throws { + let connId = UUID() + let entry = FavoriteTablesStorage.FavoriteEntry(connectionId: connId, schema: nil, name: "orders") + let record = SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID) + + #expect(record["schema"] == nil) + let decoded = try SyncRecordMapper.favoriteEntry(from: record) + #expect(decoded == entry) + } + + @Test("Two entries with same name but different connections have distinct sync IDs") + func distinctSyncIds() { + let connA = UUID() + let connB = UUID() + let entryA = FavoriteTablesStorage.FavoriteEntry(connectionId: connA, schema: nil, name: "users") + let entryB = FavoriteTablesStorage.FavoriteEntry(connectionId: connB, schema: nil, name: "users") + #expect(FavoriteTablesStorage.syncId(for: entryA) != FavoriteTablesStorage.syncId(for: entryB)) + } +} diff --git a/TableProTests/Storage/RecentTablesStoreTests.swift b/TableProTests/Storage/RecentTablesStoreTests.swift new file mode 100644 index 000000000..949647f87 --- /dev/null +++ b/TableProTests/Storage/RecentTablesStoreTests.swift @@ -0,0 +1,100 @@ +// +// 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"]) + } + + @Test("Nil database key is distinct from empty-string database") + func nilDatabaseDistinctFromEmpty() { + let store = makeStore() + let conn = UUID() + store.push(connectionID: conn, database: nil, table: makeTable("sqlite_table")) + store.push(connectionID: conn, database: "postgres", table: makeTable("pg_table")) + #expect(store.entries(connectionID: conn, database: nil).map(\.name) == ["sqlite_table"]) + #expect(store.entries(connectionID: conn, database: "postgres").map(\.name) == ["pg_table"]) + } +} diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index 230298ae4..c627f71c0 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -175,4 +175,42 @@ struct SidebarContextMenuLogicTests { let clickedTable: TableInfo? = TestFixtures.makeTableInfo(name: "users") #expect(clickedTable != nil) } + + // MARK: - Maintenance group disabled rule + + @Test("Maintenance group enabled with selection, writable, and supported ops") + func maintenanceEnabledAllConditions() { + #expect(SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: true, + supportedOperations: ["ANALYZE", "OPTIMIZE"] + )) + } + + @Test("Maintenance group disabled when read-only") + func maintenanceDisabledReadOnly() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: true, + hasSelection: true, + supportedOperations: ["ANALYZE"] + )) + } + + @Test("Maintenance group disabled with no selection") + func maintenanceDisabledNoSelection() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: false, + supportedOperations: ["ANALYZE"] + )) + } + + @Test("Maintenance group disabled when driver exposes no ops") + func maintenanceDisabledNoOps() { + #expect(!SidebarContextMenuLogic.maintenanceGroupEnabled( + isReadOnly: false, + hasSelection: true, + supportedOperations: [] + )) + } } diff --git a/TableProTests/Views/TableRowLogicTests.swift b/TableProTests/Views/TableRowLogicTests.swift index 0d219b594..e7996ab39 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -6,13 +6,12 @@ // import SwiftUI +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("TableRowLogicTests") struct TableRowLogicTests { - // MARK: - Accessibility Label @Test("Normal table accessibility label") @@ -57,6 +56,13 @@ struct TableRowLogicTests { #expect(label == "View: my_view, pending delete") } + @Test("Favorite table accessibility label") + func accessibilityLabelFavoriteTable() { + let table = TestFixtures.makeTableInfo(name: "users", type: .table) + let label = TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false, isFavorite: true) + #expect(label == "Table: users, favorite") + } + // MARK: - Icon Color @Test("Normal table icon color is system blue") diff --git a/TableProUITests/TableProLaunchUITests.swift b/TableProUITests/TableProLaunchUITests.swift new file mode 100644 index 000000000..adc61e636 --- /dev/null +++ b/TableProUITests/TableProLaunchUITests.swift @@ -0,0 +1,32 @@ +import XCTest + +final class TableProLaunchUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testApplicationLaunchesMainWindow() throws { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + XCTAssertTrue(app.windows.firstMatch.waitForExistence(timeout: 10)) + } + + func testMainWindowRespectsMinimumSize() throws { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let frame = window.frame + XCTAssertGreaterThanOrEqual(frame.width, 950, "Window width must be at least the base minimum") + XCTAssertGreaterThanOrEqual(frame.height, 480, "Window height must be at least the base minimum") + } +} 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 82% rename from docs/features/sql-favorites.mdx rename to docs/features/favorites.mdx index e90ff71ee..9f15e67a8 100644 --- a/docs/features/sql-favorites.mdx +++ b/docs/features/favorites.mdx @@ -1,13 +1,32 @@ --- -title: SQL Favorites -description: Save frequently used queries with optional keyword shortcuts for autocomplete expansion +title: Favorites +description: Mark tables as favorites and save frequently used queries with optional keyword shortcuts --- -# SQL Favorites +# Favorites + +The Tables sidebar 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 + +Every table row in the sidebar has a star button at the end. Click it to add or remove the table from favorites. A filled yellow star marks a favorite; an outlined star marks a non-favorite. Favorites: + +- Move to the top of their section +- Appear in the **Tables** group of the Favorites tab + +Double-click a table in the Favorites tab to open it. Right-click it to open the table, 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. -## Creating a Favorite +## Creating an SQL Favorite Three ways to save a favorite: diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index d2a6c6df6..cb00dd1c9 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -1,11 +1,11 @@ --- title: iCloud Sync -description: Sync connections, settings, and SSH profiles across Macs via iCloud (Pro feature) +description: Sync connections, table favorites, settings, and SSH profiles across Macs via iCloud (Pro feature) --- # iCloud Sync -TablePro syncs your connections, groups, settings, and SSH profiles across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. +TablePro syncs your connections, groups, table favorites, settings, and SSH profiles across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. ## What syncs (and what doesn't) @@ -14,6 +14,7 @@ TablePro syncs your connections, groups, settings, and SSH profiles across all y | **Connections** | Yes | Host, port, username, database type, SSH/SSL config | | **Passwords** | Optional | Opt-in via iCloud Keychain (end-to-end encrypted) | | **Groups & Tags** | Yes | Full connection organization, including nested group hierarchy (parent-child relationships and sort order) | +| **Table Favorites** | Yes | Favorited table names shown in the Favorites tab and pinned in table lists | | **App Settings** | Yes | All settings categories (General, Appearance, Editor, Keyboard, AI, Terminal) | | **Linked SQL Folders** | No | Folder paths are per-Mac. Link the same Git repo on each Mac after cloning. Cached file metadata (`linked_sql_index.db`) is also local. | @@ -39,7 +40,7 @@ Open **Settings** (`Cmd+,`) > **Account**, toggle iCloud Sync on, choose which c /> -Each data type has its own toggle: Connections, Groups & Tags, SSH Profiles, and App Settings. +Connections, Groups & Tags, SSH Profiles, and App Settings each have their own toggle. Table favorites sync when iCloud Sync is enabled. ## Excluding individual connections @@ -59,4 +60,3 @@ iCloud Sync requires a Pro license. When a license expires, sync stops but local ## Troubleshooting If no records sync, confirm iCloud is signed in and iCloud Drive is enabled, then click **Sync Now**. For "iCloud account unavailable," sign in via **System Settings** > **Apple Account**. - diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index f00e504a1..ed9a8aae3 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -92,8 +92,8 @@ TablePro opens with a sidebar-style welcome window, in the style of the Xcode la SQLite FTS5-backed history with full-text search. - - Save and reuse named queries. + + Pin tables and save reusable queries. Full shortcut reference. diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 885b84f69..53c64b8e5 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -312,5 +312,4 @@ If you save (`Cmd+S`) while the file has changed externally, TablePro shows a si ### Linked folders -For watching a whole folder of `.sql` files (e.g., a Git repo of team queries), use [Linked SQL Folders](/features/sql-favorites#linked-sql-folders) instead of opening each file by hand. Linked folders update the sidebar within a second of any on-disk change. - +For watching a whole folder of `.sql` files (e.g., a Git repo of team queries), use [Linked SQL Folders](/features/favorites#linked-sql-folders) instead of opening each file by hand. Linked folders update the sidebar within a second of any on-disk change. diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index aced3f9a7..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.