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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.

## [0.46.0] - 2026-05-28

### Added
Expand Down
7 changes: 4 additions & 3 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,10 @@ final class ConnectionStorage {
guard didMutate, saveConnections(connections) else {
return false
}
for connection in updatesById.values where !connection.localOnly && !connection.isSample {
syncTracker.markDirty(.connection, id: connection.id.uuidString)
}
let dirtyIds = updatesById.values
.filter { !$0.localOnly && !$0.isSample }
.map { $0.id.uuidString }
syncTracker.markDirty(.connection, ids: dirtyIds)
return true
}

Expand Down
8 changes: 4 additions & 4 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,15 @@ final class GroupStorage {

let storage = connectionStorageProvider()
var connections = storage.loadConnections()
var changed = false
var changed: [DatabaseConnection] = []
for i in connections.indices {
if let gid = connections[i].groupId, allIdsToDelete.contains(gid) {
connections[i].groupId = nil
changed = true
changed.append(connections[i])
}
}
if changed {
if !storage.saveConnections(connections) {
if !changed.isEmpty {
if !storage.updateConnections(changed) {
Self.logger.error("Failed to clear groupId references after group deletion")
}
}
Expand Down
26 changes: 12 additions & 14 deletions TablePro/ViewModels/WelcomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -415,10 +415,12 @@ final class WelcomeViewModel {

func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) {
let ids = Set(targets.map(\.id))
var updated: [DatabaseConnection] = []
for i in connections.indices where ids.contains(connections[i].id) {
connections[i].groupId = groupId
updated.append(connections[i])
}
guard storage.saveConnections(connections) else {
guard storage.updateConnections(updated) else {
connections = storage.loadConnections()
rebuildTree()
return
Expand All @@ -428,10 +430,12 @@ final class WelcomeViewModel {

func removeFromGroup(_ targets: [DatabaseConnection]) {
let ids = Set(targets.map(\.id))
var updated: [DatabaseConnection] = []
for i in connections.indices where ids.contains(connections[i].id) {
connections[i].groupId = nil
updated.append(connections[i])
}
guard storage.saveConnections(connections) else {
guard storage.updateConnections(updated) else {
connections = storage.loadConnections()
rebuildTree()
return
Expand Down Expand Up @@ -549,26 +553,23 @@ final class WelcomeViewModel {

let updatedValidGroupIds = Set(groups.map(\.id))
var order = 0
var dirtyIds: [String] = []
var updated: [DatabaseConnection] = []
for i in connections.indices {
let isUngrouped = connections[i].groupId.map { !updatedValidGroupIds.contains($0) } ?? true
if isUngrouped {
if connections[i].sortOrder != order {
connections[i].sortOrder = order
dirtyIds.append(connections[i].id.uuidString)
updated.append(connections[i])
}
order += 1
}
}

guard storage.saveConnections(connections) else {
guard storage.updateConnections(updated) else {
connections = storage.loadConnections()
rebuildTree()
return
}
if !dirtyIds.isEmpty {
services.syncTracker.markDirty(.connection, ids: dirtyIds)
}
rebuildTree()
}

Expand All @@ -591,23 +592,20 @@ final class WelcomeViewModel {
connections.move(fromOffsets: globalSource, toOffset: globalDestination)

var order = 0
var dirtyIds: [String] = []
var updated: [DatabaseConnection] = []
for i in connections.indices where connections[i].groupId == group.id {
if connections[i].sortOrder != order {
connections[i].sortOrder = order
dirtyIds.append(connections[i].id.uuidString)
updated.append(connections[i])
}
order += 1
}

guard storage.saveConnections(connections) else {
guard storage.updateConnections(updated) else {
connections = storage.loadConnections()
rebuildTree()
return
}
if !dirtyIds.isEmpty {
services.syncTracker.markDirty(.connection, ids: dirtyIds)
}
rebuildTree()
}

Expand Down
43 changes: 41 additions & 2 deletions TableProTests/Core/Storage/GroupStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ final class GroupStorageTests: XCTestCase {
private var syncDefaults: UserDefaults!
private var syncSuiteName: String!
private var storage: GroupStorage!
private var tracker: SyncChangeTracker!
private var connectionStorage: ConnectionStorage!
private var connectionFileURL: URL!

override func setUp() {
super.setUp()
Expand All @@ -23,18 +26,38 @@ final class GroupStorageTests: XCTestCase {
syncSuiteName = "com.TablePro.tests.Sync.\(unique)"
syncDefaults = UserDefaults(suiteName: syncSuiteName)!
let metadata = SyncMetadataStorage(userDefaults: syncDefaults)
let tracker = SyncChangeTracker(metadataStorage: metadata)
storage = GroupStorage(userDefaults: defaults, syncTracker: tracker)
tracker = SyncChangeTracker(metadataStorage: metadata)
connectionFileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("tablepro-tests")
.appendingPathComponent("group-connections_\(unique).json")
try? FileManager.default.createDirectory(
at: connectionFileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
connectionStorage = ConnectionStorage(
fileURL: connectionFileURL,
userDefaults: defaults,
syncTracker: tracker
)
storage = GroupStorage(
userDefaults: defaults,
syncTracker: tracker,
connectionStorage: connectionStorage
)
}

override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
syncDefaults.removePersistentDomain(forName: syncSuiteName)
try? FileManager.default.removeItem(at: connectionFileURL)
defaults = nil
suiteName = nil
syncDefaults = nil
syncSuiteName = nil
storage = nil
tracker = nil
connectionStorage = nil
connectionFileURL = nil
super.tearDown()
}

Expand Down Expand Up @@ -129,6 +152,22 @@ final class GroupStorageTests: XCTestCase {
XCTAssertEqual(loaded[0].name, "Prod")
}

func testDeleteGroupClearsMembershipAndMarksConnectionDirtyForSync() {
let group = ConnectionGroup(name: "Dev", color: .green)
storage.saveGroups([group])

let connection = DatabaseConnection(name: "Grouped", groupId: group.id)
connectionStorage.addConnection(connection)
tracker.clearAllDirty(.connection)

storage.deleteGroup(group)

let reloaded = connectionStorage.loadConnections()
XCTAssertEqual(reloaded.count, 1)
XCTAssertNil(reloaded[0].groupId)
XCTAssertTrue(tracker.dirtyRecords(for: .connection).contains(connection.id.uuidString))
}

// MARK: - Lookup

func testGroupForId() {
Expand Down
Loading