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 @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Internal: drop four 1-to-1 PassthroughSubject buses (`saveAsFavoriteRequested`, `focusConnectionFormWindowRequested`, `openSampleDatabaseRequested`, `resetSampleDatabaseRequested`) for direct ownership. The favorite-edit dialog query is now observable state on `MainContentCoordinator`, the connection-form focus lookup is inlined in `WelcomeViewModel`, and the sample-database menu items call `SampleDatabaseLauncher` directly. `AppDelegate` no longer needs `commandCancellables`.
- Internal: tighten dependency injection in the four files that already accept `services: AppServices`. `ConnectionFormCoordinator`, `MainContentCoordinator`, `WelcomeViewModel`, and `ERDiagramViewModel` now read `services.appEvents.*` instead of mixing in raw `AppEvents.shared.*` for events they were already supposed to thread through the container.
- Internal: extend `AppServices` with `appSettingsStorage`, `schemaProviderRegistry`, `aiKeyStorage`, `groupStorage`, `favoritesExpansionState`, and `linkedFolderWatcher`. Convert the raw `.shared` reads of those types in `AIChatViewModel`, `FavoritesSidebarViewModel`, `WelcomeViewModel`, and `MainContentCoordinator` to `services.*`.
- Internal: extend `AppServices` with `tagStorage`, `sshProfileStorage`, `licenseManager`, `conflictResolver`, and `syncMetadataStorage`. `SyncCoordinator` now takes `services: AppServices` in init (default `.live`); 34 raw `.shared` reads of those types inside `SyncCoordinator` are routed through `services.*`.
- Internal: Redis sidebar key tree uses SwiftUI `OutlineGroup` instead of recursive `DisclosureGroup` + `ForEach` wrapped in `AnyView`. Expansion state is now managed natively per branch identifier; the explicit `expandedPrefixes` set is gone.
- Result-grid cells render via direct `draw(_:)` on a layer-backed `NSView` instead of an `NSTableCellView` wrapping an `NSTextField` plus an `NSButton` accessory. Per cell during scroll there is no Auto Layout solving, no `NSTextField` re-layout, and no `NSButton` tracking-area work. Editing for plain-text columns now opens the overlay editor (the same surface previously used for multi-line cells) rather than an inline text field.

Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Services/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ struct AppServices {
let aiChatStorage: AIChatStorage
let aiKeyStorage: AIKeyStorage
let groupStorage: GroupStorage
let tagStorage: TagStorage
let sshProfileStorage: SSHProfileStorage
let licenseManager: LicenseManager
let conflictResolver: ConflictResolver
let syncMetadataStorage: SyncMetadataStorage
let favoritesExpansionState: FavoritesExpansionState
let linkedFolderWatcher: LinkedFolderWatcher
let syncTracker: SyncChangeTracker
Expand All @@ -40,6 +45,11 @@ struct AppServices {
aiChatStorage: .shared,
aiKeyStorage: .shared,
groupStorage: .shared,
tagStorage: .shared,
sshProfileStorage: .shared,
licenseManager: .shared,
conflictResolver: .shared,
syncMetadataStorage: .shared,
favoritesExpansionState: .shared,
linkedFolderWatcher: .shared,
syncTracker: .shared,
Expand Down
87 changes: 46 additions & 41 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,22 @@ final class SyncCoordinator {
private(set) var lastSyncDate: Date?
private(set) var iCloudAccountAvailable: Bool = false

@ObservationIgnored private let services: AppServices
@ObservationIgnored private let engine = CloudKitSyncEngine()
@ObservationIgnored private let changeTracker = SyncChangeTracker.shared
@ObservationIgnored private let metadataStorage = SyncMetadataStorage.shared
@ObservationIgnored private let conflictResolver = ConflictResolver.shared
@ObservationIgnored private let changeTracker: SyncChangeTracker
@ObservationIgnored private let metadataStorage: SyncMetadataStorage
@ObservationIgnored private let conflictResolver: ConflictResolver
@ObservationIgnored private var accountObserver: NSObjectProtocol?
@ObservationIgnored private var changeCancellable: AnyCancellable?
@ObservationIgnored private var licenseCancellable: AnyCancellable?
@ObservationIgnored private var syncTask: Task<Void, Never>?
@ObservationIgnored private var hasStarted = false

private init() {
init(services: AppServices = .live) {
self.services = services
self.changeTracker = services.syncTracker
self.metadataStorage = services.syncMetadataStorage
self.conflictResolver = services.conflictResolver
lastSyncDate = metadataStorage.lastSyncDate
}

Expand All @@ -53,7 +58,7 @@ final class SyncCoordinator {

// If local storage is empty (fresh install or wiped), clear the sync token
// to force a full fetch instead of a delta that returns nothing
if ConnectionStorage.shared.loadConnections().isEmpty {
if services.connectionStorage.loadConnections().isEmpty {
metadataStorage.clearSyncToken()
Self.logger.info("No local connections — cleared sync token for full fetch")
}
Expand Down Expand Up @@ -138,22 +143,22 @@ final class SyncCoordinator {
/// Marks all existing local data as dirty so it will be pushed on the next sync.
/// Called when sync is first enabled to upload existing connections/groups/tags/settings.
private func markAllLocalDataDirty() {
let connections = ConnectionStorage.shared.loadConnections()
let connections = services.connectionStorage.loadConnections()
for connection in connections where !connection.localOnly {
changeTracker.markDirty(.connection, id: connection.id.uuidString)
}

let groups = GroupStorage.shared.loadGroups()
let groups = services.groupStorage.loadGroups()
for group in groups {
changeTracker.markDirty(.group, id: group.id.uuidString)
}

let tags = TagStorage.shared.loadTags()
let tags = services.tagStorage.loadTags()
for tag in tags {
changeTracker.markDirty(.tag, id: tag.id.uuidString)
}

let sshProfiles = SSHProfileStorage.shared.loadProfiles()
let sshProfiles = services.sshProfileStorage.loadProfiles()
for profile in sshProfiles {
changeTracker.markDirty(.sshProfile, id: profile.id.uuidString)
}
Expand All @@ -175,7 +180,7 @@ final class SyncCoordinator {
// MARK: - Status

private func evaluateStatus() {
let licenseManager = LicenseManager.shared
let licenseManager = services.licenseManager

// Check license
guard licenseManager.isFeatureAvailable(.iCloudSync) else {
Expand All @@ -189,7 +194,7 @@ final class SyncCoordinator {
}

// Check sync settings
let syncSettings = AppSettingsStorage.shared.loadSync()
let syncSettings = services.appSettingsStorage.loadSync()
guard syncSettings.enabled else {
syncStatus = .disabled(.userDisabled)
return
Expand All @@ -208,13 +213,13 @@ final class SyncCoordinator {
}

private func canSync() -> Bool {
let licenseManager = LicenseManager.shared
let licenseManager = services.licenseManager
guard licenseManager.isFeatureAvailable(.iCloudSync) else {
Self.logger.trace("Sync skipped: license not available")
return false
}

let syncSettings = AppSettingsStorage.shared.loadSync()
let syncSettings = services.appSettingsStorage.loadSync()
guard syncSettings.enabled else {
Self.logger.trace("Sync skipped: disabled by user")
return false
Expand All @@ -231,7 +236,7 @@ final class SyncCoordinator {
// MARK: - Push

private func performPush() async {
let settings = AppSettingsStorage.shared.loadSync()
let settings = services.appSettingsStorage.loadSync()
var recordsToSave: [CKRecord] = []
var recordIDsToDelete: [CKRecord.ID] = []
let zoneID = await engine.zoneID
Expand All @@ -240,7 +245,7 @@ final class SyncCoordinator {
if settings.syncConnections {
let dirtyConnectionIds = changeTracker.dirtyRecords(for: .connection)
if !dirtyConnectionIds.isEmpty {
let connections = ConnectionStorage.shared.loadConnections()
let connections = services.connectionStorage.loadConnections()
for id in dirtyConnectionIds {
if let connection = connections.first(where: { $0.id.uuidString == id }),
!connection.localOnly {
Expand Down Expand Up @@ -378,9 +383,9 @@ final class SyncCoordinator {
// @MainActor and can block the UI on large sync batches. Consider moving to Task.detached
// for large payloads.
private func applyRemoteChanges(_ result: PullResult) {
let settings = AppSettingsStorage.shared.loadSync()
let settings = services.appSettingsStorage.loadSync()

ConnectionStorage.shared.invalidateCache()
services.connectionStorage.invalidateCache()

changeTracker.isSuppressed = true
defer {
Expand Down Expand Up @@ -444,30 +449,30 @@ final class SyncCoordinator {
}

if !connectionIdsToDelete.isEmpty {
var connections = ConnectionStorage.shared.loadConnections()
var connections = services.connectionStorage.loadConnections()
connections.removeAll { connectionIdsToDelete.contains($0.id) }
if !ConnectionStorage.shared.saveConnections(connections) {
if !services.connectionStorage.saveConnections(connections) {
Self.logger.error("Failed to apply remote connection deletions: persistence error")
}
}
if !groupIdsToDelete.isEmpty {
var groups = GroupStorage.shared.loadGroups()
var groups = services.groupStorage.loadGroups()
groups.removeAll { groupIdsToDelete.contains($0.id) }
GroupStorage.shared.saveGroups(groups)
services.groupStorage.saveGroups(groups)
}
if !tagIdsToDelete.isEmpty {
var tags = TagStorage.shared.loadTags()
var tags = services.tagStorage.loadTags()
tags.removeAll { tagIdsToDelete.contains($0.id) }
TagStorage.shared.saveTags(tags)
services.tagStorage.saveTags(tags)
}
if !sshProfileIdsToDelete.isEmpty {
var profiles = SSHProfileStorage.shared.loadProfiles()
var profiles = services.sshProfileStorage.loadProfiles()
profiles.removeAll { sshProfileIdsToDelete.contains($0.id) }
SSHProfileStorage.shared.saveProfilesWithoutSync(profiles)
services.sshProfileStorage.saveProfilesWithoutSync(profiles)
}

if actualConnectionChanges || groupsOrTagsChanged {
AppEvents.shared.connectionUpdated.send(())
services.appEvents.connectionUpdated.send(())
}
}

Expand All @@ -485,7 +490,7 @@ final class SyncCoordinator {
return false
}

var connections = ConnectionStorage.shared.loadConnections()
var connections = services.connectionStorage.loadConnections()
if let index = connections.firstIndex(where: { $0.id == remoteConnection.id }) {
if changeTracker.dirtyRecords(for: .connection).contains(remoteConnection.id.uuidString) {
let localRecord = SyncRecordMapper.toCKRecord(
Expand All @@ -512,7 +517,7 @@ final class SyncCoordinator {
} else {
connections.append(remoteConnection)
}
guard ConnectionStorage.shared.saveConnections(connections) else {
guard services.connectionStorage.saveConnections(connections) else {
Self.logger.error("Failed to apply remote connection update: persistence error for \(remoteConnection.id, privacy: .public)")
return false
}
Expand All @@ -524,13 +529,13 @@ final class SyncCoordinator {
guard let remoteGroup = SyncRecordMapper.toGroup(record) else { return false }
if tombstoneIds.contains(remoteGroup.id.uuidString) { return false }

var groups = GroupStorage.shared.loadGroups()
var groups = services.groupStorage.loadGroups()
if let index = groups.firstIndex(where: { $0.id == remoteGroup.id }) {
groups[index] = remoteGroup
} else {
groups.append(remoteGroup)
}
GroupStorage.shared.saveGroups(groups)
services.groupStorage.saveGroups(groups)
return true
}

Expand All @@ -539,13 +544,13 @@ final class SyncCoordinator {
guard let remoteTag = SyncRecordMapper.toTag(record) else { return false }
if tombstoneIds.contains(remoteTag.id.uuidString) { return false }

var tags = TagStorage.shared.loadTags()
var tags = services.tagStorage.loadTags()
if let index = tags.firstIndex(where: { $0.id == remoteTag.id }) {
tags[index] = remoteTag
} else {
tags.append(remoteTag)
}
TagStorage.shared.saveTags(tags)
services.tagStorage.saveTags(tags)
return true
}

Expand All @@ -559,13 +564,13 @@ final class SyncCoordinator {
}
if tombstoneIds.contains(remoteProfile.id.uuidString) { return }

var profiles = SSHProfileStorage.shared.loadProfiles()
var profiles = services.sshProfileStorage.loadProfiles()
if let index = profiles.firstIndex(where: { $0.id == remoteProfile.id }) {
profiles[index] = remoteProfile
} else {
profiles.append(remoteProfile)
}
SSHProfileStorage.shared.saveProfilesWithoutSync(profiles)
services.sshProfileStorage.saveProfilesWithoutSync(profiles)
}

private func applyRemoteSettings(_ record: CKRecord) {
Expand Down Expand Up @@ -605,7 +610,7 @@ final class SyncCoordinator {
}

private func observeLocalChanges() {
changeCancellable = AppEvents.shared.syncChangeTracked
changeCancellable = services.appEvents.syncChangeTracked
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
Expand All @@ -620,7 +625,7 @@ final class SyncCoordinator {
}

private func observeLicenseChanges() {
licenseCancellable = AppEvents.shared.licenseStatusDidChange
licenseCancellable = services.appEvents.licenseStatusDidChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
Expand Down Expand Up @@ -704,7 +709,7 @@ final class SyncCoordinator {
// MARK: - Settings Helpers

private func settingsData(for category: String) -> Data? {
let storage = AppSettingsStorage.shared
let storage = services.appSettingsStorage
let encoder = JSONEncoder()

do {
Expand All @@ -726,7 +731,7 @@ final class SyncCoordinator {
}

private func applySettingsData(_ data: Data, for category: String) throws {
let manager = AppSettingsManager.shared
let manager = services.appSettings
let decoder = JSONDecoder()

do {
Expand Down Expand Up @@ -755,7 +760,7 @@ final class SyncCoordinator {
) {
let dirtyGroupIds = changeTracker.dirtyRecords(for: .group)
if !dirtyGroupIds.isEmpty {
let groups = GroupStorage.shared.loadGroups()
let groups = services.groupStorage.loadGroups()
for id in dirtyGroupIds {
if let group = groups.first(where: { $0.id.uuidString == id }) {
records.append(SyncRecordMapper.toCKRecord(group, in: zoneID))
Expand All @@ -777,7 +782,7 @@ final class SyncCoordinator {
) {
let dirtyTagIds = changeTracker.dirtyRecords(for: .tag)
if !dirtyTagIds.isEmpty {
let tags = TagStorage.shared.loadTags()
let tags = services.tagStorage.loadTags()
for id in dirtyTagIds {
if let tag = tags.first(where: { $0.id.uuidString == id }) {
records.append(SyncRecordMapper.toCKRecord(tag, in: zoneID))
Expand All @@ -799,7 +804,7 @@ final class SyncCoordinator {
) {
let dirtyProfileIds = changeTracker.dirtyRecords(for: .sshProfile)
if !dirtyProfileIds.isEmpty {
let profiles = SSHProfileStorage.shared.loadProfiles()
let profiles = services.sshProfileStorage.loadProfiles()
for id in dirtyProfileIds {
if let profile = profiles.first(where: { $0.id.uuidString == id }) {
records.append(SyncRecordMapper.toCKRecord(profile, in: zoneID))
Expand Down
Loading