Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Database favorites can be tagged by environment, filtered in the Favorites sidebar, opened directly, and synced through iCloud. (#1553)
- Open in Window on a row inspector text field, for reading or editing a long value on a bigger surface.

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public enum SyncRecordType: String, CaseIterable, Sendable {
case favorite = "SQLFavorite"
case favoriteFolder = "SQLFavoriteFolder"
case tableFavorite = "FavoriteTable"
case favoriteDatabase = "FavoriteDatabase"
case sshProfile = "SSHProfile"

public var recordNamePrefix: String {
Expand All @@ -19,6 +20,7 @@ public enum SyncRecordType: String, CaseIterable, Sendable {
case .favorite: return "Favorite_"
case .favoriteFolder: return "FavoriteFolder_"
case .tableFavorite: return "FavoriteTable_"
case .favoriteDatabase: return "FavoriteDatabase_"
case .sshProfile: return "SSHProfile_"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ public enum FavoriteTableSyncField: String, SyncSchemaField {
]
}

/// Empty on purpose. `FavoriteDatabase` is not in the Production schema yet, so every field is
/// unverified and the gated subscript drops every write, which keeps the type inert rather than
/// letting CloudKit reject the record. Flip this set and `SyncRecordType.verifiedInProduction`
/// together in the commit that carries the refreshed `production-schema.ckdb`.
public enum FavoriteDatabaseSyncField: String, SyncSchemaField {
case connectionId
case database
case environment
case modifiedAtLocal
case schemaVersion

public static let verifiedInProduction: Set<Self> = []
}

public enum SQLFavoriteSyncField: String, SyncSchemaField {
case favoriteId
case name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public extension SyncRecordType {
case .favorite: return SQLFavoriteSyncField.declaredKeys
case .favoriteFolder: return SQLFavoriteFolderSyncField.declaredKeys
case .tableFavorite: return FavoriteTableSyncField.declaredKeys
case .favoriteDatabase: return FavoriteDatabaseSyncField.declaredKeys
case .sshProfile: return SSHProfileSyncField.declaredKeys
}
}
Expand All @@ -35,6 +36,7 @@ public extension SyncRecordType {
case .favorite: return SQLFavoriteSyncField.writableKeys
case .favoriteFolder: return SQLFavoriteFolderSyncField.writableKeys
case .tableFavorite: return FavoriteTableSyncField.writableKeys
case .favoriteDatabase: return FavoriteDatabaseSyncField.writableKeys
case .sshProfile: return SSHProfileSyncField.writableKeys
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,23 @@ struct SyncSchemaGateTests {
#expect(SampleField.deployed.productionSchemaState == .verified)
}

@Test("Every record type the app declares is currently deployed")
func allRecordTypesAreWritable() {
let gated = SyncRecordType.allCases.filter { !$0.isWritable }
/// A type lands here only between the commit that declares it and the commit that carries the
/// refreshed `production-schema.ckdb`. Anything gated and unlisted is a type that will never
/// sync, which is what this test exists to catch; anything listed and no longer gated means the
/// deploy landed and the entry is stale. Comparing sets catches both.
private static let pendingProductionDeploy: Set<SyncRecordType> = [.favoriteDatabase]

#expect(gated.isEmpty, "Gated record types: \(gated.map(\.rawValue).sorted())")
@Test("Every record type the app declares is deployed, or is explicitly awaiting deployment")
func allRecordTypesAreWritable() {
let gated = Set(SyncRecordType.allCases.filter { !$0.isWritable })

#expect(
gated == Self.pendingProductionDeploy,
"""
Gated record types: \(gated.map(\.rawValue).sorted()). \
Awaiting deployment: \(Self.pendingProductionDeploy.map(\.rawValue).sorted())
"""
)
}

@Test("Records of a deployed type are published")
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Menu/DatabaseMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ enum DatabaseMenuBuilder {
action: #selector(MainSplitViewController.editViewDefinition(_:))
),
schemaSubmenu(),
favoriteDatabaseSubmenu(),
maintenanceSubmenu(),
MenuItemFactory.item(
String(localized: "Truncate Table"),
Expand Down Expand Up @@ -91,6 +92,17 @@ enum DatabaseMenuBuilder {
])
}

private static let favoriteDatabaseDelegate = FavoriteDatabaseMenuDelegate()

/// A literal title, like every other item here: System Settings binds an App Shortcut to a menu
/// item's exact title, so a title that flipped between "Add" and "Remove" would break the bind.
/// The submenu reports the current state with a checkmark instead.
private static func favoriteDatabaseSubmenu() -> NSMenuItem {
let container = MenuItemFactory.submenu(String(localized: "Favorite Database"), items: [])
container.submenu?.delegate = favoriteDatabaseDelegate
return container
}

private static let maintenanceDelegate = MaintenanceMenuDelegate()

private static func maintenanceSubmenu() -> NSMenuItem {
Expand Down
60 changes: 60 additions & 0 deletions TablePro/Core/Menu/FavoriteDatabaseMenuDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// FavoriteDatabaseMenuDelegate.swift
// TablePro
//

import AppKit

/// Whether the active database is a favorite, and which environment it carries, depends on the live
/// connection, so the submenu is filled when it opens. Built on the same shape as
/// `SchemaMenuDelegate`, including the responder-chain lookup that resolves the window the chosen
/// item will act on.
///
/// The menu bar is the keyboard path to this feature. The row star is hover-revealed and the
/// context menus need a right-click, so without this the whole feature is pointer-only.
@MainActor
final class FavoriteDatabaseMenuDelegate: NSObject, NSMenuDelegate {
private static let setAction = #selector(MainSplitViewController.setFavoriteDatabaseEnvironment(_:))
private static let removeAction = #selector(MainSplitViewController.removeFavoriteDatabase(_:))

func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
let controller = NSApp.target(forAction: Self.setAction, to: nil, from: nil) as? MainSplitViewController
guard let actions = controller?.commandActions, actions.canFavoriteActiveDatabase else {
addPlaceholder(to: menu)
return
}
let state = FavoriteDatabaseSelectionState(environments: [actions.activeDatabaseFavoriteEnvironment])
for item in FavoriteDatabaseMenu.environmentItems(for: state) {
menu.addItem(environmentItem(item))
}
guard state.hasFavorite else { return }
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: FavoriteDatabaseMenu.removeTitle, action: Self.removeAction, keyEquivalent: ""))
}

private func environmentItem(_ entry: FavoriteDatabaseMenu.EnvironmentItem) -> NSMenuItem {
let item = NSMenuItem(title: entry.title, action: Self.setAction, keyEquivalent: "")
item.target = nil
item.representedObject = entry.environment.rawValue
item.state = entry.isOn ? .on : .off
return item
}

private func addPlaceholder(to menu: NSMenu) {
let empty = NSMenuItem(title: String(localized: "No Database Selected"), action: nil, keyEquivalent: "")
empty.isEnabled = false
menu.addItem(empty)
}

/// Keeps AppKit's key-equivalent search from rebuilding the menu on every modified keystroke,
/// which would query the favorites store for items that carry no key equivalent.
func menuHasKeyEquivalent(
_ menu: NSMenu,
for event: NSEvent,
target: AutoreleasingUnsafeMutablePointer<AnyObject?>,
action: UnsafeMutablePointer<Selector?>
) -> Bool {
false
}
}
2 changes: 2 additions & 0 deletions TablePro/Core/Services/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct AppServices {
let schemaProviderRegistry: SchemaProviderRegistry
let sqlFavoriteManager: SQLFavoriteManager
let favoriteTablesStorage: FavoriteTablesStorage
let favoriteDatabasesStorage: FavoriteDatabasesStorage
let aiChatStorage: AIChatStorage
let aiKeyStorage: AIKeyStorage
let groupStorage: GroupStorage
Expand Down Expand Up @@ -48,6 +49,7 @@ struct AppServices {
schemaProviderRegistry: .shared,
sqlFavoriteManager: .shared,
favoriteTablesStorage: .shared,
favoriteDatabasesStorage: .shared,
aiChatStorage: .shared,
aiKeyStorage: .shared,
groupStorage: .shared,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ extension MainSplitViewController {
Task { await coordinator.switchSchema(to: schema) }
}

@objc func setFavoriteDatabaseEnvironment(_ sender: Any?) {
guard let raw = (sender as? NSMenuItem)?.representedObject as? String,
let environment = FavoriteDatabaseEnvironment(rawValue: raw) else { return }
commandActions?.setActiveDatabaseFavorite(environment: environment)
}

@objc func removeFavoriteDatabase(_ sender: Any?) {
commandActions?.removeActiveDatabaseFavorite()
}

@objc func truncateTable(_ sender: Any?) {
commandActions?.truncateTables()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ struct MenuValidationContext: Equatable {
var supportsUserManagement = false
var supportsSchemaSwitching = false
var canFilterDatabases = false
var canFavoriteActiveDatabase = false
var hasDatabaseFilter = false
}

Expand Down Expand Up @@ -190,6 +191,8 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.isConnected && context.hasMaintenanceOperations
case #selector(switchToSchema(_:)):
return context.isConnected && context.supportsSchemaSwitching
case #selector(setFavoriteDatabaseEnvironment(_:)), #selector(removeFavoriteDatabase(_:)):
return context.isConnected && context.canFavoriteActiveDatabase
case #selector(filterDatabases(_:)):
return context.isConnected && context.canFilterDatabases
case #selector(showAllDatabases(_:)):
Expand Down Expand Up @@ -269,6 +272,7 @@ extension MainSplitViewController: NSMenuItemValidation {
supportsUserManagement: actions.supportsUserManagement,
supportsSchemaSwitching: actions.supportsSchemaSwitching,
canFilterDatabases: actions.canFilterDatabases,
canFavoriteActiveDatabase: actions.canFavoriteActiveDatabase,
hasDatabaseFilter: actions.hasDatabaseFilter
)
}
Expand Down
65 changes: 65 additions & 0 deletions TablePro/Core/Storage/ConnectionLocalState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// ConnectionLocalState.swift
// TablePro
//

import Foundation

/// Everything a deleted connection leaves behind on this device, cleaned up in one place.
///
/// The list used to be written out at each of the three delete sites, which is how they drifted:
/// the remote-deletion path cleared two stores where the local one cleared nine, and no site had
/// ever removed a single `SidebarPersistenceKey`. A store added here is cleaned up everywhere.
@MainActor
internal enum ConnectionLocalState {
/// Who deleted the connection. A local delete leaves tombstones so the other devices follow;
/// a remote delete must not, or it pushes back a deletion the sender already made.
internal enum Origin {
case local
case remote
}

internal static func purge(
connectionIds: Set<UUID>,
origin: Origin,
appSettings: AppSettingsStorage = .shared
) {
guard !connectionIds.isEmpty else { return }

for connectionId in connectionIds {
purgeLiveState(connectionId)
appSettings.saveLastDatabase(nil, for: connectionId)
appSettings.saveLastSchema(nil, for: connectionId)
purgeFavorites(connectionId, origin: origin)
SidebarPersistenceKey.removeAll(connectionId: connectionId)
RecentTablesStore.shared.removeEntries(for: connectionId)
HistoryPanelPreferencesStorage.remove(for: connectionId)
QueryInsightsPreferencesStorage.remove(for: connectionId)
}

FilterSettingsStorage.shared.removeFilters(for: connectionIds)
DatabaseTreeFilterStorage.shared.removeFilters(for: connectionIds)
RecentlyClosedTabStore.shared.removeEntries(for: connectionIds)
}

/// The in-memory registries go first. A live `SharedSidebarState` for this connection rewrites
/// its own defaults keys on the next mutation, so removing the keys under it achieves nothing.
private static func purgeLiveState(_ connectionId: UUID) {
SharedSidebarState.removeConnection(connectionId)
SidebarViewModel.removeConnection(connectionId)
HistoryPanelState.removeConnection(connectionId)
QuickSwitcherCatalogStore.shared.removeConnection(connectionId)
FavoritesExpansionState.shared.removeConnection(connectionId)
}

private static func purgeFavorites(_ connectionId: UUID, origin: Origin) {
switch origin {
case .local:
FavoriteTablesStorage.shared.removeFavorites(for: connectionId)
FavoriteDatabasesStorage.shared.removeFavorites(for: connectionId)
case .remote:
FavoriteTablesStorage.shared.removeFavoritesWithoutSync(for: connectionId)
FavoriteDatabasesStorage.shared.removeFavoritesWithoutSync(for: connectionId)
}
}
}
31 changes: 10 additions & 21 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,16 +287,11 @@ final class ConnectionStorage {
let secureFieldIds = Self.secureFieldIds(for: connection.type)
deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds)

let appSettings = appSettingsProvider()
appSettings.saveLastDatabase(nil, for: connection.id)
appSettings.saveLastSchema(nil, for: connection.id)

FavoriteTablesStorage.shared.removeFavorites(for: connection.id)
FilterSettingsStorage.shared.removeFilters(for: connection.id)
DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id)
RecentlyClosedTabStore.shared.removeEntries(for: connection.id)
HistoryPanelPreferencesStorage.remove(for: connection.id)
QueryInsightsPreferencesStorage.remove(for: connection.id)
ConnectionLocalState.purge(
connectionIds: [connection.id],
origin: .local,
appSettings: appSettingsProvider()
)
Task {
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: connection.id)
await QueryHistoryManager.shared.clear(
Expand Down Expand Up @@ -331,18 +326,12 @@ final class ConnectionStorage {
deleteSOCKSProxyPassword(for: conn.id)
let fields = Self.secureFieldIds(for: conn.type)
deleteAllPluginSecureFields(for: conn.id, fieldIds: fields)
let appSettings = appSettingsProvider()
appSettings.saveLastDatabase(nil, for: conn.id)
appSettings.saveLastSchema(nil, for: conn.id)
FavoriteTablesStorage.shared.removeFavorites(for: conn.id)
}
FilterSettingsStorage.shared.removeFilters(for: idsToDelete)
DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete)
RecentlyClosedTabStore.shared.removeEntries(for: idsToDelete)
for id in idsToDelete {
HistoryPanelPreferencesStorage.remove(for: id)
QueryInsightsPreferencesStorage.remove(for: id)
}
ConnectionLocalState.purge(
connectionIds: idsToDelete,
origin: .local,
appSettings: appSettingsProvider()
)
Task {
for conn in connectionsToDelete {
await SQLFavoriteManager.shared.removeFavoritesAndFolders(for: conn.id)
Expand Down
Loading
Loading