Skip to content
Open
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

- Databases can be saved to the Favorites sidebar and grouped as Development, Testing, Production or Unassigned. Right-click a database in the Tree sidebar to add it, then use the environment filter or activate it directly from Favorites. (#1553)
- Tree sidebar layouts group tables, views, materialized views, foreign tables, procedures and functions into collapsible folders inside each database or schema. (#1590)
- Dameng DM8 connections through a downloadable native-wire plugin, with schema browsing, table editing, metadata, DDL, transactions, Unicode and binary writes, EXPLAIN support, and query cancellation with a query timeout. Stopping a DM8 query closes its connection, because DM8 has no out-of-band cancel request; TablePro reconnects on the next query. (#1671, #2003, #2010)
- SQL Server connections can sign in with Microsoft Entra ID, covering Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2022. Sign-in runs in your browser and honours multifactor authentication and Conditional Access; tokens are kept in the keychain and refreshed for you. Set it up on the Mac; iPhone and iPad pick the connection up through sync and prompt to sign in when you open it.
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ final class ConnectionStorage {
appSettings.saveLastSchema(nil, for: connection.id)

FavoriteTablesStorage.shared.removeFavorites(for: connection.id)
FavoriteDatabasesStorage.shared.removeFavorites(for: connection.id)
FavoritesExpansionState.shared.removeConnection(connection.id)
FilterSettingsStorage.shared.removeFilters(for: connection.id)
DatabaseTreeFilterStorage.shared.removeFilter(for: connection.id)
RecentlyClosedTabStore.shared.removeEntries(for: connection.id)
Expand Down Expand Up @@ -331,6 +333,8 @@ final class ConnectionStorage {
appSettings.saveLastDatabase(nil, for: conn.id)
appSettings.saveLastSchema(nil, for: conn.id)
FavoriteTablesStorage.shared.removeFavorites(for: conn.id)
FavoriteDatabasesStorage.shared.removeFavorites(for: conn.id)
FavoritesExpansionState.shared.removeConnection(conn.id)
}
FilterSettingsStorage.shared.removeFilters(for: idsToDelete)
DatabaseTreeFilterStorage.shared.removeFilters(for: idsToDelete)
Expand Down
87 changes: 87 additions & 0 deletions TablePro/Core/Storage/FavoriteDatabasesStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// FavoriteDatabasesStorage.swift
// TablePro
//

import Foundation
import os

extension Notification.Name {
internal static let favoriteDatabasesDidChange = Notification.Name("FavoriteDatabasesDidChange")
}

@MainActor
internal final class FavoriteDatabasesStorage {
internal static let shared = FavoriteDatabasesStorage()

private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteDatabasesStorage")
private let defaults: UserDefaults

internal init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) {
self.defaults = defaults
}

internal func favorites(for connectionId: UUID) -> Set<FavoriteDatabaseEntry> {
guard let data = defaults.data(forKey: key(for: connectionId)),
let decoded = try? JSONDecoder().decode(Set<FavoriteDatabaseEntry>.self, from: data)
else { return [] }
return decoded.filter { $0.connectionId == connectionId && !$0.database.isEmpty }
}

internal func environment(
for database: String,
connectionId: UUID
) -> FavoriteDatabaseEnvironment? {
favorites(for: connectionId).first { $0.database == database }?.environment
}

internal func setFavorite(
database: String,
environment: FavoriteDatabaseEnvironment,
connectionId: UUID
) {
guard !database.isEmpty else { return }
var entries = favorites(for: connectionId)
entries = Set(entries.filter { $0.database != database })
entries.insert(FavoriteDatabaseEntry(
connectionId: connectionId,
database: database,
environment: environment
))
persist(entries, connectionId: connectionId)
}

internal func removeFavorite(database: String, connectionId: UUID) {
var entries = favorites(for: connectionId)
let originalCount = entries.count
entries = Set(entries.filter { $0.database != database })
guard entries.count != originalCount else { return }
persist(entries, connectionId: connectionId)
}

internal func removeFavorites(for connectionId: UUID) {
let key = key(for: connectionId)
guard defaults.object(forKey: key) != nil else { return }
defaults.removeObject(forKey: key)
NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self)
}

private func persist(_ entries: Set<FavoriteDatabaseEntry>, connectionId: UUID) {
let key = key(for: connectionId)
guard !entries.isEmpty else {
defaults.removeObject(forKey: key)
NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self)
return
}
do {
defaults.set(try JSONEncoder().encode(entries), forKey: key)
NotificationCenter.default.post(name: .favoriteDatabasesDidChange, object: self)
} catch {
Self.logger.error("Failed to encode favorite databases: \(error.localizedDescription, privacy: .public)")
}
}

private func key(for connectionId: UUID) -> String {
"com.TablePro.favoriteDatabases.\(connectionId.uuidString)"
}
}
4 changes: 4 additions & 0 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,10 @@ final class SyncCoordinator {
Self.logger.error("Failed to apply remote connection deletions: persistence error")
} else {
FilterSettingsStorage.shared.removeFilters(for: connectionIdsToDelete)
for id in connectionIdsToDelete {
FavoriteDatabasesStorage.shared.removeFavorites(for: id)
FavoritesExpansionState.shared.removeConnection(id)
}
let favoriteManager = services.sqlFavoriteManager
Task {
for id in connectionIdsToDelete {
Expand Down
40 changes: 40 additions & 0 deletions TablePro/Models/Favorites/FavoriteDatabaseEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// FavoriteDatabaseEntry.swift
// TablePro
//

import Foundation

internal struct FavoriteDatabaseEntry: Codable, Hashable, Identifiable, Sendable {
internal let connectionId: UUID
internal let database: String
internal let environment: FavoriteDatabaseEnvironment

internal var id: String {
"\(connectionId.uuidString)\u{1}\(database)"
}

internal init(
connectionId: UUID,
database: String,
environment: FavoriteDatabaseEnvironment
) {
self.connectionId = connectionId
self.database = database
self.environment = environment
}

private enum CodingKeys: String, CodingKey {
case connectionId
case database
case environment
}

internal init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
connectionId = try container.decode(UUID.self, forKey: .connectionId)
database = try container.decode(String.self, forKey: .database)
let rawEnvironment = try container.decodeIfPresent(String.self, forKey: .environment)
environment = rawEnvironment.flatMap(FavoriteDatabaseEnvironment.init(rawValue:)) ?? .none
}
}
38 changes: 38 additions & 0 deletions TablePro/Models/Favorites/FavoriteDatabaseEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// FavoriteDatabaseEnvironment.swift
// TablePro
//

import Foundation

internal enum FavoriteDatabaseEnvironment: String, CaseIterable, Codable, Sendable {
case development
case testing
case production
case none

internal var title: String {
switch self {
case .development: String(localized: "Development")
case .testing: String(localized: "Testing")
case .production: String(localized: "Production")
case .none: String(localized: "Unassigned")
}
}

internal var menuTitle: String {
switch self {
case .none: String(localized: "No Environment")
case .development, .testing, .production: title
}
}

internal var iconName: String {
switch self {
case .development: "wrench.and.screwdriver"
case .testing: "checkmark.circle"
case .production: "lock.shield"
case .none: "tray"
}
}
}
34 changes: 34 additions & 0 deletions TablePro/Models/Favorites/FavoriteDatabaseEnvironmentFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// FavoriteDatabaseEnvironmentFilter.swift
// TablePro
//

import Foundation

internal enum FavoriteDatabaseEnvironmentFilter: String, CaseIterable, Sendable {
case all
case development
case testing
case production
case unassigned

internal var title: String {
switch self {
case .all: String(localized: "All Environments")
case .development: FavoriteDatabaseEnvironment.development.title
case .testing: FavoriteDatabaseEnvironment.testing.title
case .production: FavoriteDatabaseEnvironment.production.title
case .unassigned: FavoriteDatabaseEnvironment.none.title
}
}

internal var environment: FavoriteDatabaseEnvironment? {
switch self {
case .all: nil
case .development: .development
case .testing: .testing
case .production: .production
case .unassigned: FavoriteDatabaseEnvironment.none
}
}
}
13 changes: 13 additions & 0 deletions TablePro/Models/Favorites/FavoriteDatabaseGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// FavoriteDatabaseGroup.swift
// TablePro
//

import Foundation

internal struct FavoriteDatabaseGroup: Equatable, Identifiable, Sendable {
internal let environment: FavoriteDatabaseEnvironment
internal let entries: [FavoriteDatabaseEntry]

internal var id: String { environment.rawValue }
}
33 changes: 33 additions & 0 deletions TablePro/Models/Favorites/FavoriteDatabaseGrouping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// FavoriteDatabaseGrouping.swift
// TablePro
//

import Foundation

internal enum FavoriteDatabaseGrouping {
internal static func groups(
entries: Set<FavoriteDatabaseEntry>,
searchText: String,
filter: FavoriteDatabaseEnvironmentFilter
) -> [FavoriteDatabaseGroup] {
let filtered = entries.filter { entry in
guard filter.environment == nil || entry.environment == filter.environment else { return false }
guard !searchText.isEmpty else { return true }
return entry.database.localizedStandardContains(searchText)
|| entry.environment.title.localizedStandardContains(searchText)
}

return FavoriteDatabaseEnvironment.allCases.compactMap { environment in
let matching = filtered
.filter { $0.environment == environment }
.sorted {
let comparison = $0.database.localizedStandardCompare($1.database)
if comparison != .orderedSame { return comparison == .orderedAscending }
return $0.id < $1.id
}
guard !matching.isEmpty else { return nil }
return FavoriteDatabaseGroup(environment: environment, entries: matching)
}
}
}
14 changes: 14 additions & 0 deletions TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ final class SharedSidebarState {
}
}

var favoriteDatabaseEnvironmentFilter: FavoriteDatabaseEnvironmentFilter {
didSet {
AppStorageEnvironment.shared.defaults.set(
favoriteDatabaseEnvironmentFilter.rawValue,
forKey: SidebarPersistenceKey.favoriteDatabaseEnvironmentFilter(connectionId: connectionId)
)
}
}

var selectedFavorite: FavoriteSelection? {
didSet {
guard oldValue != selectedFavorite else { return }
Expand Down Expand Up @@ -156,6 +165,10 @@ final class SharedSidebarState {
self.sidebarLayout = SharedSidebarState.defaultLayout
}
self.databaseFilterSelected = DatabaseTreeFilterStorage.shared.selectedDatabases(connectionId: connectionId)
let environmentFilterKey = SidebarPersistenceKey.favoriteDatabaseEnvironmentFilter(connectionId: connectionId)
self.favoriteDatabaseEnvironmentFilter = AppStorageEnvironment.shared.defaults
.string(forKey: environmentFilterKey)
.flatMap(FavoriteDatabaseEnvironmentFilter.init(rawValue:)) ?? .all
self.selectedFavorite = AppStorageEnvironment.shared.defaults.string(
forKey: SidebarPersistenceKey.selectedFavorite(connectionId: connectionId)
).flatMap(FavoriteSelection.init(rawValue:))
Expand All @@ -170,6 +183,7 @@ final class SharedSidebarState {
self.selectedSidebarTab = .tables
self.sidebarLayout = .flat
self.databaseFilterSelected = []
self.favoriteDatabaseEnvironmentFilter = .all
self.selectedFavorite = nil
}

Expand Down
Loading
Loading