Skip to content

Commit 76b06e5

Browse files
committed
fix(sidebar): address PR review blockers and design concerns
- feat(sync): add syncTableFavorites toggle to SyncSettings and SyncSection - refactor(sidebar): scope FavoriteTablesStorage by (connectionId, schema, name) instead of global table name - fix(sync): gate tableFavorite push/apply/clear behind syncTableFavorites setting - fix(storage): add NSLock to FavoriteTablesStorage for thread safety - refactor(storage): change RecentTablesStore.Key.database to String? with nilIfEmpty convention - fix(changelog): add View ER Diagram removal entry - test: update FavoriteTablesStorageTests for connection-scoped favorites - test: update SyncRecordMapperFavoriteTableTests with connectionId and schema - test: add nil-database test to RecentTablesStoreTests - test(ui): add window minimum size assertion
1 parent 6277d9e commit 76b06e5

15 files changed

Lines changed: 254 additions & 113 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
### Removed
3232

3333
- "Create New Table…" from the sidebar right-click menu. Use the plus button next to the sidebar filter instead.
34+
- "View ER Diagram" from the sidebar right-click menu. Access it from the Favorites tab context menu instead.
3435

3536
### Fixed
3637

CLAUDE.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ These govern every decision — code, architecture, tooling, and process:
1212
4. **Clean code** — self-explanatory naming, early returns over nested conditionals, small focused functions. No comments in the codebase — code must be self-documenting through clear naming and structure.
1313
5. **Root cause fixes** — don't patch symptoms. Diagnose the underlying issue, add logging to debug if needed, then fix the actual cause.
1414
6. **No hacky solutions** — no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix.
15-
7. **Testability**every testable code change needs unit/function tests, and UI/user-flow changes need UI automation when deterministic. When tests fail, fix the source code — never adjust tests to match incorrect output.
15+
7. **Testability**if a feature is testable, write tests. When tests fail, fix the source code — never adjust tests to match incorrect output.
1616
8. **Maintainability** — follow existing patterns but offer refactors when they improve quality. Extract into extensions when approaching size limits. Group by domain logic.
1717
9. **Scalability** — design for the plugin system's open-ended nature. `DatabaseType` is a struct, not an enum. All switches need `default:`.
1818

@@ -52,7 +52,6 @@ swiftformat . # Format code
5252
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation
5353
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName
5454
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName
55-
xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests
5655

5756
# DMG
5857
scripts/create-dmg.sh
@@ -224,7 +223,7 @@ These are **non-negotiable** — never skip them:
224223
- Settings changes → `docs/customization/settings.mdx`
225224
- Database driver changes → `docs/databases/*.mdx`
226225
227-
4. **Tests**: Every code change must include or update unit/function tests for testable behavior. UI and user-flow changes must also include or update `TableProUITests` UI automation when the flow can run deterministically; if not, state the blocker in the handoff. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior.
226+
4. **Tests**: Write tests for testable features. When tests fail, fix the source code — never adjust tests to match incorrect output. Tests define expected behavior.
228227
229228
5. **Lint after changes**: Run `swiftlint lint --strict` to verify compliance.
230229

TablePro/Core/Storage/FavoriteTablesStorage.swift

Lines changed: 81 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
//
2-
// FavoriteTablesStorage.swift
3-
// TablePro
4-
//
51

62
import Foundation
73
import os
@@ -14,77 +10,117 @@ final class FavoriteTablesStorage {
1410
static let shared = FavoriteTablesStorage()
1511
private static let logger = Logger(subsystem: "com.TablePro", category: "FavoriteTablesStorage")
1612

13+
struct FavoriteEntry: Codable, Hashable {
14+
let connectionId: UUID
15+
let schema: String?
16+
let name: String
17+
}
18+
1719
private let defaults: UserDefaults
1820
private let syncTracker: SyncChangeTracker
1921
private let key = "com.TablePro.favoriteTables"
20-
private var cache: Set<String>?
22+
private var cache: Set<FavoriteEntry>?
23+
private let lock = NSLock()
2124

2225
init(userDefaults: UserDefaults = .standard, syncTracker: SyncChangeTracker = .shared) {
2326
self.defaults = userDefaults
2427
self.syncTracker = syncTracker
2528
}
2629

27-
func loadFavorites() -> Set<String> {
28-
if let cache { return cache }
29-
guard let data = defaults.data(forKey: key),
30-
let decoded = try? JSONDecoder().decode(Set<String>.self, from: data) else {
31-
cache = []
32-
return []
33-
}
34-
cache = decoded
35-
return decoded
30+
func loadFavorites() -> Set<FavoriteEntry> {
31+
lock.lock()
32+
defer { lock.unlock() }
33+
return _loadFavorites()
34+
}
35+
36+
func favorites(for connectionId: UUID) -> Set<FavoriteEntry> {
37+
lock.lock()
38+
defer { lock.unlock() }
39+
return _loadFavorites().filter { $0.connectionId == connectionId }
3640
}
3741

38-
func isFavorite(_ name: String) -> Bool {
39-
loadFavorites().contains(name)
42+
func isFavorite(name: String, schema: String?, connectionId: UUID) -> Bool {
43+
lock.lock()
44+
defer { lock.unlock() }
45+
return _loadFavorites().contains(FavoriteEntry(connectionId: connectionId, schema: schema, name: name))
4046
}
4147

42-
func toggle(_ name: String) {
43-
if isFavorite(name) {
44-
removeFavorite(name)
48+
func toggle(name: String, schema: String?, connectionId: UUID) {
49+
let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name)
50+
lock.lock()
51+
var favorites = _loadFavorites()
52+
let isPresent = favorites.contains(entry)
53+
lock.unlock()
54+
55+
if isPresent {
56+
removeFavorite(name: name, schema: schema, connectionId: connectionId)
4557
} else {
46-
addFavorite(name)
58+
addFavorite(name: name, schema: schema, connectionId: connectionId)
4759
}
4860
}
4961

50-
func addFavorite(_ name: String) {
51-
var favorites = loadFavorites()
52-
guard favorites.insert(name).inserted else { return }
53-
persist(favorites)
54-
syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: name))
62+
func addFavorite(name: String, schema: String?, connectionId: UUID) {
63+
let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name)
64+
lock.lock()
65+
var favorites = _loadFavorites()
66+
guard favorites.insert(entry).inserted else { lock.unlock(); return }
67+
_persist(favorites)
68+
lock.unlock()
69+
syncTracker.markDirty(.tableFavorite, id: Self.syncId(for: entry))
5570
}
5671

57-
func addFavoriteWithoutSync(_ name: String) {
58-
var favorites = loadFavorites()
59-
guard favorites.insert(name).inserted else { return }
60-
persist(favorites)
72+
func addFavoriteWithoutSync(_ entry: FavoriteEntry) {
73+
lock.lock()
74+
var favorites = _loadFavorites()
75+
guard favorites.insert(entry).inserted else { lock.unlock(); return }
76+
_persist(favorites)
77+
lock.unlock()
6178
}
6279

63-
func removeFavorite(_ name: String) {
64-
var favorites = loadFavorites()
65-
guard favorites.remove(name) != nil else { return }
66-
persist(favorites)
67-
syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: name))
80+
func removeFavorite(name: String, schema: String?, connectionId: UUID) {
81+
let entry = FavoriteEntry(connectionId: connectionId, schema: schema, name: name)
82+
lock.lock()
83+
var favorites = _loadFavorites()
84+
guard favorites.remove(entry) != nil else { lock.unlock(); return }
85+
_persist(favorites)
86+
lock.unlock()
87+
syncTracker.markDeleted(.tableFavorite, id: Self.syncId(for: entry))
6888
}
6989

70-
func removeFavoriteWithoutSync(_ name: String) {
71-
var favorites = loadFavorites()
72-
guard favorites.remove(name) != nil else { return }
73-
persist(favorites)
90+
func removeFavoriteWithoutSync(_ entry: FavoriteEntry) {
91+
lock.lock()
92+
var favorites = _loadFavorites()
93+
guard favorites.remove(entry) != nil else { lock.unlock(); return }
94+
_persist(favorites)
95+
lock.unlock()
7496
}
7597

7698
func removeFavoriteWithoutSync(id: String) {
77-
var favorites = loadFavorites()
78-
guard let name = favorites.first(where: { Self.syncId(for: $0) == id }) else { return }
79-
favorites.remove(name)
80-
persist(favorites)
99+
lock.lock()
100+
var favorites = _loadFavorites()
101+
guard let entry = favorites.first(where: { Self.syncId(for: $0) == id }) else { lock.unlock(); return }
102+
favorites.remove(entry)
103+
_persist(favorites)
104+
lock.unlock()
105+
}
106+
107+
static func syncId(for entry: FavoriteEntry) -> String {
108+
let raw = entry.connectionId.uuidString + "|" + (entry.schema ?? "") + "|" + entry.name
109+
return raw.sha256
81110
}
82111

83-
static func syncId(for name: String) -> String {
84-
name.sha256
112+
private func _loadFavorites() -> Set<FavoriteEntry> {
113+
if let cache { return cache }
114+
guard let data = defaults.data(forKey: key),
115+
let decoded = try? JSONDecoder().decode(Set<FavoriteEntry>.self, from: data) else {
116+
cache = []
117+
return []
118+
}
119+
cache = decoded
120+
return decoded
85121
}
86122

87-
private func persist(_ favorites: Set<String>) {
123+
private func _persist(_ favorites: Set<FavoriteEntry>) {
88124
cache = favorites
89125
guard let data = try? JSONEncoder().encode(favorites) else {
90126
Self.logger.error("Failed to encode favorite tables")

TablePro/Core/Storage/RecentTablesStore.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class RecentTablesStore {
1515

1616
struct Key: Hashable {
1717
let connectionID: UUID
18-
let database: String
18+
let database: String?
1919
}
2020

2121
struct Entry: Hashable, Identifiable {
@@ -32,7 +32,7 @@ final class RecentTablesStore {
3232

3333
init() {}
3434

35-
func push(connectionID: UUID, database: String, table: TableInfo) {
35+
func push(connectionID: UUID, database: String?, table: TableInfo) {
3636
let key = Key(connectionID: connectionID, database: database)
3737
var list = entriesByKey[key] ?? []
3838
let newEntryId = entryId(name: table.name, schema: table.schema)
@@ -53,11 +53,11 @@ final class RecentTablesStore {
5353
NotificationCenter.default.post(name: .recentTablesDidChange, object: nil)
5454
}
5555

56-
func entries(connectionID: UUID, database: String) -> [Entry] {
56+
func entries(connectionID: UUID, database: String?) -> [Entry] {
5757
entriesByKey[Key(connectionID: connectionID, database: database)] ?? []
5858
}
5959

60-
func clear(connectionID: UUID, database: String) {
60+
func clear(connectionID: UUID, database: String?) {
6161
entriesByKey.removeValue(forKey: Key(connectionID: connectionID, database: database))
6262
NotificationCenter.default.post(name: .recentTablesDidChange, object: nil)
6363
}

TablePro/Core/Sync/SyncCoordinator.swift

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ final class SyncCoordinator {
168168
}
169169

170170
let favoriteTables = services.favoriteTablesStorage.loadFavorites()
171-
for tableName in favoriteTables {
172-
changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: tableName))
171+
for entry in favoriteTables {
172+
changeTracker.markDirty(.tableFavorite, id: FavoriteTablesStorage.syncId(for: entry))
173173
}
174174

175175
// Mark all settings categories as dirty
@@ -304,7 +304,9 @@ final class SyncCoordinator {
304304
}
305305
}
306306

307-
collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID)
307+
if settings.syncTableFavorites {
308+
collectDirtyTableFavorites(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID)
309+
}
308310

309311
// Deduplicate deletion IDs to prevent CloudKit "can't delete same record twice" error
310312
let uniqueDeletions = Array(Set(recordIDsToDelete))
@@ -327,7 +329,9 @@ final class SyncCoordinator {
327329
if settings.syncSettings {
328330
changeTracker.clearAllDirty(.settings)
329331
}
330-
changeTracker.clearAllDirty(.tableFavorite)
332+
if settings.syncTableFavorites {
333+
changeTracker.clearAllDirty(.tableFavorite)
334+
}
331335

332336
// Clear tombstones only for types that were actually pushed
333337
if settings.syncConnections {
@@ -353,8 +357,10 @@ final class SyncCoordinator {
353357
metadataStorage.removeTombstone(type: .settings, id: tombstone.id)
354358
}
355359
}
356-
for tombstone in metadataStorage.tombstones(for: .tableFavorite) {
357-
metadataStorage.removeTombstone(type: .tableFavorite, id: tombstone.id)
360+
if settings.syncTableFavorites {
361+
for tombstone in metadataStorage.tombstones(for: .tableFavorite) {
362+
metadataStorage.removeTombstone(type: .tableFavorite, id: tombstone.id)
363+
}
358364
}
359365

360366
Self.logger.info("Push completed: \(recordsToSave.count) saved, \(recordIDsToDelete.count) deleted")
@@ -442,7 +448,7 @@ final class SyncCoordinator {
442448
applyRemoteSSHProfile(record, tombstoneIds: sshTombstoneIds)
443449
case SyncRecordType.settings.rawValue where settings.syncSettings:
444450
applyRemoteSettings(record)
445-
case SyncRecordType.tableFavorite.rawValue:
451+
case SyncRecordType.tableFavorite.rawValue where settings.syncTableFavorites:
446452
applyRemoteTableFavorite(record, tombstoneIds: tableFavoriteTombstoneIds)
447453
default:
448454
break
@@ -622,9 +628,9 @@ final class SyncCoordinator {
622628

623629
@discardableResult
624630
private func applyRemoteTableFavorite(_ record: CKRecord, tombstoneIds: Set<String>) -> Bool {
625-
let name: String
631+
let entry: FavoriteTablesStorage.FavoriteEntry
626632
do {
627-
name = try SyncRecordMapper.favoriteTableName(from: record)
633+
entry = try SyncRecordMapper.favoriteEntry(from: record)
628634
} catch {
629635
let recordName = record.recordID.recordName
630636
let message = error.localizedDescription
@@ -633,9 +639,9 @@ final class SyncCoordinator {
633639
)
634640
return false
635641
}
636-
if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: name)) { return false }
642+
if tombstoneIds.contains(FavoriteTablesStorage.syncId(for: entry)) { return false }
637643
let before = services.favoriteTablesStorage.loadFavorites()
638-
services.favoriteTablesStorage.addFavoriteWithoutSync(name)
644+
services.favoriteTablesStorage.addFavoriteWithoutSync(entry)
639645
return before != services.favoriteTablesStorage.loadFavorites()
640646
}
641647

@@ -887,8 +893,8 @@ final class SyncCoordinator {
887893
let dirtyIds = changeTracker.dirtyRecords(for: .tableFavorite)
888894
if !dirtyIds.isEmpty {
889895
let favorites = services.favoriteTablesStorage.loadFavorites()
890-
for name in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: name)) {
891-
records.append(SyncRecordMapper.toCKRecord(favoriteTableName: name, in: zoneID))
896+
for entry in favorites where dirtyIds.contains(FavoriteTablesStorage.syncId(for: entry)) {
897+
records.append(SyncRecordMapper.toCKRecord(favoriteEntry: entry, in: zoneID))
892898
}
893899
}
894900

TablePro/Core/Sync/SyncRecordMapper.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,24 +327,33 @@ struct SyncRecordMapper {
327327

328328
// MARK: - Table Favorite
329329

330-
static func toCKRecord(favoriteTableName name: String, in zone: CKRecordZone.ID) -> CKRecord {
331-
let favoriteId = FavoriteTablesStorage.syncId(for: name)
330+
static func toCKRecord(favoriteEntry entry: FavoriteTablesStorage.FavoriteEntry, in zone: CKRecordZone.ID) -> CKRecord {
331+
let favoriteId = FavoriteTablesStorage.syncId(for: entry)
332332
let recordID = recordID(type: .tableFavorite, id: favoriteId, in: zone)
333333
let record = CKRecord(recordType: SyncRecordType.tableFavorite.rawValue, recordID: recordID)
334334

335335
record["favoriteTableId"] = favoriteId as CKRecordValue
336-
record["name"] = name as CKRecordValue
336+
record["connectionId"] = entry.connectionId.uuidString as CKRecordValue
337+
record["name"] = entry.name as CKRecordValue
338+
if let schema = entry.schema {
339+
record["schema"] = schema as CKRecordValue
340+
}
337341
record["modifiedAtLocal"] = Date() as CKRecordValue
338342
record["schemaVersion"] = schemaVersion as CKRecordValue
339343

340344
return record
341345
}
342346

343-
static func favoriteTableName(from record: CKRecord) throws -> String {
347+
static func favoriteEntry(from record: CKRecord) throws -> FavoriteTablesStorage.FavoriteEntry {
344348
guard let name = record["name"] as? String, !name.isEmpty else {
345349
throw SyncDecodeError.missingRequiredField("name")
346350
}
347-
return name
351+
guard let connectionIdString = record["connectionId"] as? String,
352+
let connectionId = UUID(uuidString: connectionIdString) else {
353+
throw SyncDecodeError.missingRequiredField("connectionId")
354+
}
355+
let schema = record["schema"] as? String
356+
return FavoriteTablesStorage.FavoriteEntry(connectionId: connectionId, schema: schema, name: name)
348357
}
349358

350359
// MARK: - SSH Profile

0 commit comments

Comments
 (0)