From 69087f964e327fcf7bcca76ac32c97d9c463cbaa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 30 Jul 2026 23:04:56 -0700 Subject: [PATCH 1/2] Add failing same-directory workspace restore regressions --- ...rkspaceRecoveryReviewRegressionTests.swift | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift b/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift index dd5099cf58a8..5e54fd4f4d0f 100644 --- a/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift +++ b/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift @@ -13,6 +13,72 @@ import Testing @MainActor @Suite(.serialized) struct WorkspaceRecoveryReviewRegressionTests { + @Test + func sessionRestoreKeepsDistinctCustomizationForWorkspacesSharingADirectory() throws { + let fixture = try makeCustomizationStore() + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let directory = "/tmp/shared-workspace-customization" + let snapshots = try distinctWorkspaceSnapshots(in: directory) + fixture.store.setCustomTitle("Directory Label", for: directory) + fixture.store.setCustomColor("#ABCDEF", for: directory) + + let restoredManager = TabManager( + autoWelcomeIfNeeded: false, + workspaceDirectoryCustomizationStore: fixture.store + ) + restoredManager.restoreSessionSnapshot(SessionTabManagerSnapshot( + selectedWorkspaceIndex: 0, + workspaces: snapshots + )) + + let restoredByStableId = Dictionary( + uniqueKeysWithValues: restoredManager.tabs.map { ($0.stableId, $0) } + ) + let firstStableId = try #require(snapshots[0].stableId) + let secondStableId = try #require(snapshots[1].stableId) + let first = try #require(restoredByStableId[firstStableId]) + let second = try #require(restoredByStableId[secondStableId]) + #expect(first.customTitle == "First Workspace") + #expect(first.customColor == "#112233") + #expect(second.customTitle == "Second Workspace") + #expect(second.customColor == "#445566") + } + + @Test + func closedWorkspaceRestoreKeepsDistinctCustomizationForWorkspacesSharingADirectory() throws { + let fixture = try makeCustomizationStore() + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let directory = "/tmp/shared-closed-workspace-customization" + let snapshots = try distinctWorkspaceSnapshots(in: directory) + fixture.store.setCustomTitle("Directory Label", for: directory) + fixture.store.setCustomColor("#ABCDEF", for: directory) + let restoredManager = TabManager( + autoWelcomeIfNeeded: false, + workspaceDirectoryCustomizationStore: fixture.store + ) + + for (index, snapshot) in snapshots.enumerated() { + #expect(restoredManager.restoreClosedWorkspace(ClosedWorkspaceHistoryEntry( + workspaceId: try #require(snapshot.workspaceId), + windowId: nil, + workspaceIndex: index, + snapshot: snapshot + ))) + } + + let restoredByStableId = Dictionary( + uniqueKeysWithValues: restoredManager.tabs.map { ($0.stableId, $0) } + ) + let firstStableId = try #require(snapshots[0].stableId) + let secondStableId = try #require(snapshots[1].stableId) + let first = try #require(restoredByStableId[firstStableId]) + let second = try #require(restoredByStableId[secondStableId]) + #expect(first.customTitle == "First Workspace") + #expect(first.customColor == "#112233") + #expect(second.customTitle == "Second Workspace") + #expect(second.customColor == "#445566") + } + @Test func generatedProWorkspaceDoesNotOverwriteStickyProjectIdentity() throws { _ = NSApplication.shared @@ -254,6 +320,31 @@ struct WorkspaceRecoveryReviewRegressionTests { return window } + private func distinctWorkspaceSnapshots( + in directory: String + ) throws -> [SessionWorkspaceSnapshot] { + let manager = TabManager( + initialWorkingDirectory: directory, + autoWelcomeIfNeeded: false + ) + let first = try #require(manager.selectedWorkspace) + let second = manager.addWorkspace( + workingDirectory: directory, + inheritWorkingDirectory: false, + select: false, + placementOverride: .end + ) + #expect(manager.setCustomTitle(tabId: first.id, title: "First Workspace")) + #expect(manager.setCustomTitle(tabId: second.id, title: "Second Workspace")) + manager.setTabColor(tabId: first.id, color: "#112233") + manager.setTabColor(tabId: second.id, color: "#445566") + + let snapshots = manager.sessionSnapshot(includeScrollback: false).workspaces + #expect(snapshots.map(\.customTitle) == ["First Workspace", "Second Workspace"]) + #expect(snapshots.map(\.customColor) == ["#112233", "#445566"]) + return snapshots + } + private func workspaceRecord( index: Int, from workspace: Workspace From 9f586e457454eb6bfb7d9a2eceab305d44cafd1a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Fri, 31 Jul 2026 01:41:29 -0700 Subject: [PATCH 2/2] Persist workspace customization by stable identity --- ...roupCoordinator+DeletionConfirmation.swift | 2 +- .../WorkspaceGroupCoordinator.swift | 9 +- .../Coordinators/WorkspaceGroupHosting.swift | 4 +- .../WorkspaceCustomization.swift | 38 +++ .../WorkspaceCustomizationPersistence.swift | 43 +++ .../WorkspaceCustomizationStore.swift | 215 ++++++++++++++ .../WorkspaceDirectoryCustomization.swift | 2 +- ...ceDirectoryCustomizationCreationMode.swift | 7 - ...rectoryCustomizationPersistenceEntry.swift | 2 +- ...toryCustomizationPersistenceSnapshot.swift | 2 +- ...WorkspaceDirectoryCustomizationStore.swift | 6 +- .../WorkspaceCustomizationStoreTests.swift | 128 +++++++++ .../WorkspaceCoordinatorTests.swift | 2 +- Sources/AppDelegate.swift | 32 ++- .../CmuxConfigExecutor+WorkspaceLaunch.swift | 2 +- Sources/RemoteTmuxController.swift | 2 +- Sources/TabManager+DetachedWorkspace.swift | 3 +- ...er+WorkspaceCustomizationPersistence.swift | 142 ++++++++++ ...ager+WorkspaceDirectoryCustomization.swift | 138 --------- Sources/TabManager.swift | 40 +-- Sources/Workspace.swift | 3 - Sources/cmuxApp.swift | 4 +- cmux.xcodeproj/project.pbxproj | 8 +- .../CmuxConfigWorkspaceActionTests.swift | 11 +- ...rkspaceRecoveryReviewRegressionTests.swift | 205 ++------------ cmuxTests/WorkspaceRecoveryTests.swift | 268 ++++-------------- 26 files changed, 711 insertions(+), 607 deletions(-) create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomization.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationPersistence.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationStore.swift delete mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationCreationMode.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Customization/WorkspaceCustomizationStoreTests.swift create mode 100644 Sources/TabManager+WorkspaceCustomizationPersistence.swift delete mode 100644 Sources/TabManager+WorkspaceDirectoryCustomization.swift diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator+DeletionConfirmation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator+DeletionConfirmation.swift index 7d2f8a77152c..4a2cb6de6635 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator+DeletionConfirmation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator+DeletionConfirmation.swift @@ -110,7 +110,7 @@ extension WorkspaceGroupCoordinator { initialBrowserTransparentBackground: false, inheritWorkingDirectory: true, select: true, - workspaceDirectoryCustomizationMode: .trackDirectory + applyCreationTitleAsCustomTitle: true ) } let countBefore = model.tabs.count diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift index bfb8adb93d30..de2116f30cdc 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift @@ -118,8 +118,9 @@ public final class WorkspaceGroupCoordinator { /// Create a brand-new workspace inheriting the anchor's cwd, attach it /// to the group, and position it within the group's tabs[] range per - /// `placement`. The customization flag lets generated-purpose workspaces - /// opt out of inheriting project identity. Returns the new workspace. + /// `placement`. Generated-purpose workspaces can keep a creation title as + /// automatic metadata instead of adopting it as a user-owned custom title. + /// Returns the new workspace. @discardableResult public func createWorkspaceInGroup( groupId: UUID, @@ -131,7 +132,7 @@ public final class WorkspaceGroupCoordinator { initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode = .trackDirectory + applyCreationTitleAsCustomTitle: Bool = true ) -> Tab? { guard let host else { return nil } // nil resolves to the stored global default at call time, matching the @@ -149,7 +150,7 @@ public final class WorkspaceGroupCoordinator { initialBrowserTransparentBackground: initialBrowserTransparentBackground, inheritWorkingDirectory: cwd == nil, select: select, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) model.assignGroup(workspaceId: newWorkspace.id, groupId: groupId) placeWithinGroup( diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupHosting.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupHosting.swift index 0a6e9b2c186b..b8c5a8097cd9 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupHosting.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupHosting.swift @@ -29,7 +29,7 @@ public protocol WorkspaceGroupHosting: WorkspaceOrderHosting { select: Bool ) -> Tab /// Creates a member workspace for `createWorkspaceInGroup`, preserving the - /// initial-surface options and directory-customization tracking mode. + /// initial-surface options and creation-title ownership policy. func createWorkspaceForGroup( title: String?, workingDirectory: String?, @@ -39,7 +39,7 @@ public protocol WorkspaceGroupHosting: WorkspaceOrderHosting { initialBrowserTransparentBackground: Bool, inheritWorkingDirectory: Bool, select: Bool, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode + applyCreationTitleAsCustomTitle: Bool ) -> Tab /// Closes a member workspace during group deletion (legacy /// `closeWorkspace(_:recordHistory:)`, including its teardown chain). diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomization.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomization.swift new file mode 100644 index 000000000000..f96c85126f14 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomization.swift @@ -0,0 +1,38 @@ +/// One independently persisted workspace customization field. +/// +/// `absent` means the recovery journal has never observed a mutation for the +/// field, while `cleared` is an explicit tombstone that prevents a stale +/// session snapshot from resurrecting an older value. +public enum WorkspaceCustomizationField: Codable, Equatable, Sendable { + /// The field has no recovery-journal entry, so the session snapshot wins. + case absent + /// The most recent user mutation assigned the associated value. + case value(String) + /// The most recent user mutation explicitly cleared the field. + case cleared +} + +/// User-owned workspace identity persisted independently of session autosave. +/// +/// Records are keyed by `Workspace.stableId`. Title and color are independent +/// so mutating one field never makes the other field authoritative. +public struct WorkspaceCustomization: Codable, Equatable, Sendable { + /// Recovery state for the user-owned workspace title. + public let customTitle: WorkspaceCustomizationField + + /// Recovery state for the user-owned workspace accent color. + public let customColor: WorkspaceCustomizationField + + /// Creates a workspace customization recovery record. + /// + /// - Parameters: + /// - customTitle: Recovery state for the workspace title. + /// - customColor: Recovery state for the workspace accent color. + public init( + customTitle: WorkspaceCustomizationField = .absent, + customColor: WorkspaceCustomizationField = .absent + ) { + self.customTitle = customTitle + self.customColor = customColor + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationPersistence.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationPersistence.swift new file mode 100644 index 000000000000..3a817c7bb8c1 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationPersistence.swift @@ -0,0 +1,43 @@ +import Foundation + +struct WorkspaceCustomizationPersistenceEntry: Codable, Equatable, Sendable { + let customization: WorkspaceCustomization + let revision: UInt64 +} + +struct WorkspaceCustomizationPersistenceSnapshot: Codable, Sendable { + static let currentVersion = 1 + + var version = currentVersion + var nextRevision: UInt64 + var entries: [String: WorkspaceCustomizationPersistenceEntry] + + init( + nextRevision: UInt64 = 0, + entries: [String: WorkspaceCustomizationPersistenceEntry] = [:] + ) { + self.nextRevision = nextRevision + self.entries = entries + } + + mutating func set(_ customization: WorkspaceCustomization, for key: String) { + nextRevision &+= 1 + entries[key] = WorkspaceCustomizationPersistenceEntry( + customization: customization, + revision: nextRevision + ) + } + + mutating func trim(to capacity: Int) { + guard entries.count > capacity else { return } + entries = Dictionary(uniqueKeysWithValues: entries + .sorted { lhs, rhs in + if lhs.value.revision != rhs.value.revision { + return lhs.value.revision > rhs.value.revision + } + return lhs.key < rhs.key + } + .prefix(capacity) + .map { ($0.key, $0.value) }) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationStore.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationStore.swift new file mode 100644 index 000000000000..8a51efa46fc2 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceCustomizationStore.swift @@ -0,0 +1,215 @@ +public import Foundation + +/// Persists bounded user-owned identity recovery records by workspace identity. +/// +/// The session snapshot remains the baseline. This journal records immediate +/// user mutations so a quit before the next autosave cannot lose a rename, +/// recolor, or explicit clear. +@MainActor +public struct WorkspaceCustomizationStore { + /// Production key for the stable-workspace-ID recovery journal. + public nonisolated static let defaultStorageKey = "workspaceCustomizations.v2" + + /// Directory-keyed data written by older cmux versions. + public nonisolated static let defaultLegacyStorageKey = + WorkspaceDirectoryCustomizationStore.defaultStorageKey + + /// Maximum number of most-recently-mutated workspaces retained. + public nonisolated static let defaultCapacity = 512 + + private let defaults: UserDefaults? + private let storageKey: String + private let legacyStorageKey: String + private let capacity: Int + + /// Creates a store backed by the supplied defaults suite. + /// + /// Passing `nil` creates a no-op store for callers that do not need durable + /// customization recovery. + /// + /// - Parameters: + /// - defaults: The defaults suite that owns the recovery journal. + /// - storageKey: The key under which the stable-ID journal is encoded. + /// - legacyStorageKey: The key containing directory-keyed data to migrate. + /// - capacity: The maximum number of workspace identities retained. + public init( + defaults: UserDefaults? = nil, + storageKey: String = WorkspaceCustomizationStore.defaultStorageKey, + legacyStorageKey: String = WorkspaceCustomizationStore.defaultLegacyStorageKey, + capacity: Int = WorkspaceCustomizationStore.defaultCapacity + ) { + self.defaults = defaults + self.storageKey = storageKey + self.legacyStorageKey = legacyStorageKey + self.capacity = max(1, capacity) + } + + /// Reads the recovery record for one stable workspace identity. + /// + /// - Parameter stableId: The stable workspace identity. + /// - Returns: The recorded customization, or `nil` when none exists. + public func customization(for stableId: UUID) -> WorkspaceCustomization? { + loadSnapshot().entries[stableId.uuidString]?.customization + } + + /// Reads a batch of recovery records with one defaults decode. + /// + /// - Parameter stableIds: The stable workspace identities to read. + /// - Returns: The available recovery records keyed by stable identity. + public func customizations(for stableIds: [UUID]) -> [UUID: WorkspaceCustomization] { + let requested = Set(stableIds) + guard !requested.isEmpty else { return [:] } + let entries = loadSnapshot().entries + return Dictionary(uniqueKeysWithValues: requested.compactMap { stableId in + entries[stableId.uuidString].map { (stableId, $0.customization) } + }) + } + + /// Records the latest explicit workspace-title mutation. + /// + /// - Parameters: + /// - title: The title to record, or `nil` to record an explicit clear. + /// - stableId: The stable workspace identity. + public func setCustomTitle(_ title: String?, for stableId: UUID) { + let field = normalizedField(title) + updateCustomization(for: stableId) { current in + WorkspaceCustomization( + customTitle: field, + customColor: current?.customColor ?? .absent + ) + } + } + + /// Records the latest explicit workspace-color mutation. + /// + /// - Parameters: + /// - color: The color to record, or `nil` to record an explicit clear. + /// - stableId: The stable workspace identity. + public func setCustomColor(_ color: String?, for stableId: UUID) { + setCustomColor(color, for: [stableId]) + } + + /// Records one color mutation for several independent workspaces. + /// + /// - Parameters: + /// - color: The color to record, or `nil` to record an explicit clear. + /// - stableIds: The stable workspace identities to update. + public func setCustomColor(_ color: String?, for stableIds: [UUID]) { + let keys = Set(stableIds.map(\.uuidString)) + guard !keys.isEmpty else { return } + let field = normalizedField(color) + updateCustomizations(forKeys: keys) { current in + WorkspaceCustomization( + customTitle: current?.customTitle ?? .absent, + customColor: field + ) + } + } + + /// Promotes unambiguous legacy directory records, then removes all v1 data. + /// + /// Callers provide only directories that map to exactly one restored + /// workspace across the complete restore set. Ambiguous and orphaned + /// directory records are deliberately discarded. + /// + /// - Parameter stableIdByDirectory: Unambiguous legacy directory owners. + public func migrateLegacyDirectoryCustomizations( + toStableIdsByDirectory stableIdByDirectory: [String: UUID] + ) { + guard let defaults else { return } + let legacyStore = WorkspaceDirectoryCustomizationStore( + defaults: defaults, + storageKey: legacyStorageKey, + capacity: capacity + ) + let legacy = legacyStore.customizations( + forDirectories: Array(stableIdByDirectory.keys) + ) + for (directory, stableId) in stableIdByDirectory { + guard let normalizedDirectory = legacyStore.directoryKey(for: directory), + let customization = legacy[normalizedDirectory] else { + continue + } + updateCustomization(for: stableId) { current in + WorkspaceCustomization( + customTitle: current?.customTitle == .absent || current == nil + ? self.migratedField(customization.customTitle) + : current?.customTitle ?? .absent, + customColor: current?.customColor == .absent || current == nil + ? self.migratedField(customization.customColor) + : current?.customColor ?? .absent + ) + } + } + defaults.removeObject(forKey: legacyStorageKey) + } + + /// Normalizes a legacy workspace root solely for v1 migration matching. + /// + /// - Parameter directory: A legacy workspace root path. + /// - Returns: The normalized migration key, or `nil` for a blank path. + public func legacyDirectoryKey(for directory: String?) -> String? { + WorkspaceDirectoryCustomizationStore().directoryKey(for: directory) + } + + @discardableResult + private func updateCustomization( + for stableId: UUID, + _ transform: (WorkspaceCustomization?) -> WorkspaceCustomization + ) -> WorkspaceCustomization { + var result = WorkspaceCustomization() + updateCustomizations(forKeys: [stableId.uuidString]) { current in + result = transform(current) + return result + } + return result + } + + private func updateCustomizations( + forKeys keys: Set, + transform: (WorkspaceCustomization?) -> WorkspaceCustomization + ) { + var snapshot = loadSnapshot() + for key in keys.sorted() { + snapshot.set(transform(snapshot.entries[key]?.customization), for: key) + } + snapshot.trim(to: capacity) + persist(snapshot) + } + + private func normalizedField(_ value: String?) -> WorkspaceCustomizationField { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? .cleared : .value(trimmed) + } + + private func migratedField(_ value: String?) -> WorkspaceCustomizationField { + value.map(WorkspaceCustomizationField.value) ?? .cleared + } + + private func loadSnapshot() -> WorkspaceCustomizationPersistenceSnapshot { + guard let data = defaults?.data(forKey: storageKey), + var snapshot = try? JSONDecoder().decode( + WorkspaceCustomizationPersistenceSnapshot.self, + from: data + ), + snapshot.version == WorkspaceCustomizationPersistenceSnapshot.currentVersion else { + return WorkspaceCustomizationPersistenceSnapshot() + } + let previousCount = snapshot.entries.count + snapshot.trim(to: capacity) + if snapshot.entries.count != previousCount { + persist(snapshot) + } + return snapshot + } + + private func persist(_ snapshot: WorkspaceCustomizationPersistenceSnapshot) { + guard let defaults else { return } + guard !snapshot.entries.isEmpty else { + defaults.removeObject(forKey: storageKey) + return + } + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: storageKey) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomization.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomization.swift index 603911cb2454..677a0515b7ba 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomization.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomization.swift @@ -1,4 +1,4 @@ -/// The user-owned identity that cmux reapplies when a directory becomes a workspace again. +/// Legacy v1 directory-keyed customization decoded during stable-ID migration. public struct WorkspaceDirectoryCustomization: Codable, Equatable, Sendable { /// The explicit user-owned workspace label. public let customTitle: String? diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationCreationMode.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationCreationMode.swift deleted file mode 100644 index 42c0ccea40ef..000000000000 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationCreationMode.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// How a freshly-created workspace participates in directory customization. -public enum WorkspaceDirectoryCustomizationCreationMode: Equatable, Sendable { - /// Do not associate the workspace with a directory customization record. - case disabled - /// Track the workspace directory so later user title/color changes persist. - case trackDirectory -} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceEntry.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceEntry.swift index 1c04b24b2c23..54dc951abbda 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceEntry.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceEntry.swift @@ -1,6 +1,6 @@ import Foundation -/// One persisted sticky customization with its mutation-recency revision. +/// One legacy directory customization with its mutation-recency revision. struct WorkspaceDirectoryCustomizationPersistenceEntry: Codable, Equatable, Sendable { let customization: WorkspaceDirectoryCustomization let revision: UInt64 diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceSnapshot.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceSnapshot.swift index 0293537e3c19..3cadfda08625 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceSnapshot.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationPersistenceSnapshot.swift @@ -1,6 +1,6 @@ import Foundation -/// The bounded, versioned persistence envelope for sticky directory customizations. +/// The legacy bounded persistence envelope retained for v1 migration. struct WorkspaceDirectoryCustomizationPersistenceSnapshot: Codable, Sendable { static let currentVersion = 2 diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationStore.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationStore.swift index b25a71c751c0..ea701b9e9c67 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationStore.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Customization/WorkspaceDirectoryCustomizationStore.swift @@ -1,9 +1,9 @@ public import Foundation -/// Persists bounded user-owned workspace identity independently from live workspace lifetimes. +/// Reads and writes the legacy v1 directory-keyed customization payload. /// -/// `UserDefaults` is the sole source of truth. The store deliberately keeps no -/// in-memory mirror, so independently constructed window graphs cannot drift. +/// Production mutation and restore paths use ``WorkspaceCustomizationStore``. +/// This type remains only so existing defaults can be decoded and migrated. @MainActor public struct WorkspaceDirectoryCustomizationStore { /// The production defaults key for the versioned directory snapshot. diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Customization/WorkspaceCustomizationStoreTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Customization/WorkspaceCustomizationStoreTests.swift new file mode 100644 index 000000000000..1923f092549d --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Customization/WorkspaceCustomizationStoreTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +@testable import CmuxWorkspaces + +@MainActor +@Suite("Workspace customization store", .serialized) +struct WorkspaceCustomizationStoreTests { + @Test("persists independent title and color state by stable workspace id") + func persistenceAndFieldIndependence() throws { + let fixture = try makeFixture() + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let firstId = UUID() + let secondId = UUID() + + fixture.store.setCustomTitle("First", for: firstId) + fixture.store.setCustomColor("#123456", for: secondId) + + let reloaded = WorkspaceCustomizationStore( + defaults: fixture.defaults, + storageKey: fixture.storageKey, + legacyStorageKey: fixture.legacyStorageKey + ) + #expect( + reloaded.customization(for: firstId) == + WorkspaceCustomization( + customTitle: .value("First"), + customColor: .absent + ) + ) + #expect( + reloaded.customization(for: secondId) == + WorkspaceCustomization( + customTitle: .absent, + customColor: .value("#123456") + ) + ) + } + + @Test("explicit clears persist as field-specific tombstones") + func explicitClears() throws { + let fixture = try makeFixture() + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let stableId = UUID() + + fixture.store.setCustomTitle("Title", for: stableId) + fixture.store.setCustomColor("#ABCDEF", for: stableId) + fixture.store.setCustomTitle(nil, for: stableId) + + #expect( + fixture.store.customization(for: stableId) == + WorkspaceCustomization( + customTitle: .cleared, + customColor: .value("#ABCDEF") + ) + ) + } + + @Test("retention is bounded by stable workspace identity") + func boundedRetention() throws { + let fixture = try makeFixture(capacity: 2) + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let firstId = UUID() + let secondId = UUID() + let thirdId = UUID() + + fixture.store.setCustomTitle("First", for: firstId) + fixture.store.setCustomTitle("Second", for: secondId) + fixture.store.setCustomColor("#111111", for: firstId) + fixture.store.setCustomTitle("Third", for: thirdId) + + #expect(fixture.store.customization(for: firstId)?.customColor == .value("#111111")) + #expect(fixture.store.customization(for: secondId) == nil) + #expect(fixture.store.customization(for: thirdId)?.customTitle == .value("Third")) + } + + @Test("legacy migration promotes only supplied unambiguous directory owners") + func legacyMigration() throws { + let fixture = try makeFixture() + defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } + let legacy = WorkspaceDirectoryCustomizationStore( + defaults: fixture.defaults, + storageKey: fixture.legacyStorageKey + ) + legacy.setCustomTitle("Unique", for: "/tmp/unique") + legacy.setCustomColor("#123456", for: "/tmp/unique") + legacy.setCustomTitle("Ambiguous", for: "/tmp/ambiguous") + let stableId = UUID() + + fixture.store.migrateLegacyDirectoryCustomizations( + toStableIdsByDirectory: ["/tmp/unique": stableId] + ) + + #expect( + fixture.store.customization(for: stableId) == + WorkspaceCustomization( + customTitle: .value("Unique"), + customColor: .value("#123456") + ) + ) + #expect(fixture.defaults.object(forKey: fixture.legacyStorageKey) == nil) + } + + private func makeFixture(capacity: Int = 512) throws -> ( + store: WorkspaceCustomizationStore, + defaults: UserDefaults, + suiteName: String, + storageKey: String, + legacyStorageKey: String + ) { + let suiteName = "WorkspaceCustomizationStore.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + let storageKey = "test.workspace-customizations" + let legacyStorageKey = "test.directory-customizations" + return ( + WorkspaceCustomizationStore( + defaults: defaults, + storageKey: storageKey, + legacyStorageKey: legacyStorageKey, + capacity: capacity + ), + defaults, + suiteName, + storageKey, + legacyStorageKey + ) + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift index 1824d5316db8..5c4e81b0725a 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift @@ -69,7 +69,7 @@ final class StubGroupHost: WorkspaceGroupHosting { initialBrowserTransparentBackground: Bool, inheritWorkingDirectory: Bool, select: Bool, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode + applyCreationTitleAsCustomTitle: Bool ) -> CoordinatorStubTab { let tab = CoordinatorStubTab(currentDirectory: workingDirectory ?? "/tmp") model.tabs.append(tab) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f5faddec3e95..5e4cb50991e4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3430,6 +3430,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return false } let startupSnapshot = startupSessionSnapshot + primaryContext.tabManager.prepareLegacyWorkspaceCustomizationMigration( + afterRestoring: startupSnapshot?.windows.flatMap(\.tabManager.workspaces) ?? [] + ) let primaryWindowSnapshot = startupSnapshot?.windows.first if let primaryWindowSnapshot { if !isApplyingSessionRestore { @@ -3544,6 +3547,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) guard !snapshotWindows.isEmpty else { return false } + (tabManager ?? mainWindowContexts.values.first?.tabManager)? + .prepareLegacyWorkspaceCustomizationMigration( + afterRestoring: snapshotWindows.flatMap(\.tabManager.workspaces) + ) if !isApplyingSessionRestore { SurfaceResumeRunPromptBatch.shared.beginRestorePass() } @@ -7377,7 +7384,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: url, initialBrowserOmnibarVisible: false, initialBrowserTransparentBackground: true, - workspaceDirectoryCustomizationMode: .disabled, + applyCreationTitleAsCustomTitle: false, focusInitialBrowserAddressBarOnCreate: false, createdWorkspaceHandler: { workspace in createdWorkspace = workspace @@ -7427,7 +7434,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode = .trackDirectory, + applyCreationTitleAsCustomTitle: Bool = true, focusInitialBrowserAddressBarOnCreate: Bool = true, createdWorkspaceHandler: ((Workspace) -> Void)? = nil ) -> Bool { @@ -7471,7 +7478,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) closeInitialWorkspaceIfNeeded( initialWorkspaceId: initialWorkspace?.id, @@ -7520,7 +7527,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) else { return false } @@ -7539,7 +7546,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) createdWorkspaceHandler?(workspace) if initialSurface == .browser, focusInitialBrowserAddressBarOnCreate { @@ -7554,7 +7561,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode, + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle, event: event, debugSource: debugSource ) { @@ -8338,7 +8345,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode = .trackDirectory, + applyCreationTitleAsCustomTitle: Bool = true, shouldBringToFront: Bool = false, event: NSEvent? = nil, debugSource: String = "unspecified" @@ -8394,7 +8401,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, select: true, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } else if workingDirectory != nil || initialTerminalInput != nil { workspace = context.tabManager.addWorkspace( @@ -8403,13 +8410,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialTerminalInput: initialTerminalInput, select: true, autoWelcomeIfNeeded: initialTerminalInput == nil, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } else if title != nil { workspace = context.tabManager.addWorkspace( title: title, select: true, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } else { workspace = context.tabManager.addTab(select: true) @@ -8758,9 +8765,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent initialTerminalInput: initialTerminalInput, autoWelcomeIfNeeded: initialTerminalInput == nil, pullRequestProbeService: pullRequestProbeService, - workspaceDirectoryCustomizationStore: WorkspaceDirectoryCustomizationStore( - defaults: .standard - ), + workspaceCustomizationStore: self.tabManager?.workspaceCustomizationStore + ?? WorkspaceCustomizationStore(defaults: .standard), nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker ) tabManager.windowId = windowId diff --git a/Sources/CmuxConfigExecutor+WorkspaceLaunch.swift b/Sources/CmuxConfigExecutor+WorkspaceLaunch.swift index 26793c423ddf..c3b79af2fbae 100644 --- a/Sources/CmuxConfigExecutor+WorkspaceLaunch.swift +++ b/Sources/CmuxConfigExecutor+WorkspaceLaunch.swift @@ -153,7 +153,7 @@ extension CmuxConfigExecutor { titleSource: .auto, workingDirectory: resolvedCwd, workspaceEnvironment: wsDef.env ?? [:], - workspaceDirectoryCustomizationMode: .disabled + applyCreationTitleAsCustomTitle: false ) tabManager.setCustomTitle(tabId: newWorkspace.id, title: workspaceName, source: .auto) if let color = wsDef.color { diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 7acace72f954..05c4cc2e808e 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -304,7 +304,7 @@ final class RemoteTmuxController { title: sessionName, titleSource: .auto, select: false, autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationMode: .disabled + applyCreationTitleAsCustomTitle: false ) workspace.isRemoteTmuxMirror = true workspace.remoteTmuxWindowOrderSync = { [weak self, weak workspace] orderedPanelIds, verification in diff --git a/Sources/TabManager+DetachedWorkspace.swift b/Sources/TabManager+DetachedWorkspace.swift index 225e6bde9695..f3eed51a3377 100644 --- a/Sources/TabManager+DetachedWorkspace.swift +++ b/Sources/TabManager+DetachedWorkspace.swift @@ -80,9 +80,8 @@ extension TabManager { applyCreationChromeInheritance(to: newWorkspace, from: sourceWorkspace ?? capturedTabs.first) newWorkspace.owningTabManager = self - trackWorkspaceDirectoryCustomization( + applyCreationWorkspaceCustomization( to: newWorkspace, - rootDirectory: workingDirectory, explicitTitle: title, explicitTitleSource: titleSource ) diff --git a/Sources/TabManager+WorkspaceCustomizationPersistence.swift b/Sources/TabManager+WorkspaceCustomizationPersistence.swift new file mode 100644 index 000000000000..e7789475bbbf --- /dev/null +++ b/Sources/TabManager+WorkspaceCustomizationPersistence.swift @@ -0,0 +1,142 @@ +import CmuxWorkspaces +import Foundation + +extension TabManager { + /// Migrates v1 directory records only when one restored workspace owns the directory. + func prepareLegacyWorkspaceCustomizationMigration( + afterRestoring snapshots: [SessionWorkspaceSnapshot] + ) { + var candidatesByDirectory: [String: [UUID?]] = [:] + for snapshot in snapshots { + guard let directory = legacyWorkspaceCustomizationDirectory( + afterRestoring: snapshot + ) else { + continue + } + candidatesByDirectory[directory, default: []].append(snapshot.stableId) + } + + var uniqueStableIdsByDirectory: [String: UUID] = [:] + for (directory, candidates) in candidatesByDirectory { + guard candidates.count == 1, + let stableId = candidates[0] else { + continue + } + uniqueStableIdsByDirectory[directory] = stableId + } + workspaceCustomizationStore.migrateLegacyDirectoryCustomizations( + toStableIdsByDirectory: uniqueStableIdsByDirectory + ) + } + + /// Reads recovery records needed by one restore with a single defaults decode. + func cachedWorkspaceCustomizations( + afterRestoring snapshots: [SessionWorkspaceSnapshot] + ) -> [UUID: WorkspaceCustomization] { + workspaceCustomizationStore.customizations( + for: snapshots.compactMap(\.stableId) + ) + } + + /// Applies an explicit creation title without coupling identity to a directory. + func applyCreationWorkspaceCustomization( + to workspace: Workspace, + explicitTitle: String?, + explicitTitleSource: Workspace.CustomTitleSource + ) { + guard let explicitTitle else { return } + workspace.setCustomTitle(explicitTitle, source: explicitTitleSource) + recordWorkspaceCustomTitle(workspace, source: explicitTitleSource) + } + + /// Applies stable-ID recovery data after the snapshot has restored its own identity. + func reconcileWorkspaceCustomization( + afterRestoring snapshot: SessionWorkspaceSnapshot, + to workspace: Workspace + ) { + guard let stableId = snapshot.stableId, + workspace.stableId == stableId, + let stored = workspaceCustomizationStore.customization(for: stableId) else { + return + } + applyWorkspaceCustomization(stored, to: workspace) + } + + /// Applies one cached stable-ID recovery record. + func reconcileWorkspaceCustomization( + afterRestoring snapshot: SessionWorkspaceSnapshot, + to workspace: Workspace, + cachedCustomizations: [UUID: WorkspaceCustomization] + ) { + guard let stableId = snapshot.stableId, + workspace.stableId == stableId, + let stored = cachedCustomizations[stableId] else { + return + } + applyWorkspaceCustomization(stored, to: workspace) + } + + func recordWorkspaceCustomTitle( + _ workspace: Workspace, + source: Workspace.CustomTitleSource + ) { + guard source == .user else { return } + workspaceCustomizationStore.setCustomTitle( + workspace.customTitle, + for: workspace.stableId + ) + } + + func applyWorkspaceColor(_ color: String?, to workspaces: [Workspace]) { + guard !workspaces.isEmpty else { return } + for workspace in workspaces { + workspace.setCustomColor(color) + } + workspaceCustomizationStore.setCustomColor( + workspaces.first?.customColor, + for: workspaces.map(\.stableId) + ) + } + + private func applyWorkspaceCustomization( + _ customization: WorkspaceCustomization, + to workspace: Workspace + ) { + switch customization.customTitle { + case .absent: + break + case let .value(title): + workspace.setCustomTitle(title) + case .cleared: + workspace.setCustomTitle(nil) + } + + switch customization.customColor { + case .absent: + break + case let .value(color): + workspace.setCustomColor(color) + case .cleared: + workspace.setCustomColor(nil) + } + } + + private func legacyWorkspaceCustomizationDirectory( + afterRestoring snapshot: SessionWorkspaceSnapshot + ) -> String? { + if snapshot.usesWorkspaceDirectoryCustomization == false { + return nil + } + if let directory = snapshot.customizationDirectory { + return workspaceCustomizationStore.legacyDirectoryKey(for: directory) + } + guard snapshot.usesWorkspaceDirectoryCustomization == nil, + snapshot.remote == nil, + (snapshot.currentDirectory as NSString).isAbsolutePath else { + return nil + } + return workspaceCustomizationStore.legacyDirectoryKey( + for: snapshot.currentDirectory + ) + } +} diff --git a/Sources/TabManager+WorkspaceDirectoryCustomization.swift b/Sources/TabManager+WorkspaceDirectoryCustomization.swift deleted file mode 100644 index aedecb0f55d1..000000000000 --- a/Sources/TabManager+WorkspaceDirectoryCustomization.swift +++ /dev/null @@ -1,138 +0,0 @@ -import CmuxWorkspaces -import Foundation - -extension TabManager { - /// Reads the sticky records needed by one session restore with a single defaults decode. - func cachedWorkspaceDirectoryCustomizations( - afterRestoring snapshots: [SessionWorkspaceSnapshot] - ) -> [String: WorkspaceDirectoryCustomization] { - workspaceDirectoryCustomizationStore.customizations( - forDirectories: snapshots.compactMap(workspaceCustomizationDirectory(afterRestoring:)) - ) - } - - /// Tracks a fresh workspace's root so future user-owned title/color changes persist. - func trackWorkspaceDirectoryCustomization( - to workspace: Workspace, - rootDirectory: String?, - explicitTitle: String?, - explicitTitleSource: Workspace.CustomTitleSource - ) { - let directoryKey = workspaceDirectoryCustomizationStore.directoryKey( - for: rootDirectory - ) - guard let directoryKey else { return } - workspace.customizationDirectory = directoryKey - - if let explicitTitle { - workspace.setCustomTitle(explicitTitle, source: explicitTitleSource) - recordWorkspaceCustomTitle(workspace, source: explicitTitleSource) - } - } - - /// Resolves the sticky-customization root carried by a restored snapshot. - func workspaceCustomizationDirectory( - afterRestoring snapshot: SessionWorkspaceSnapshot - ) -> String? { - if snapshot.usesWorkspaceDirectoryCustomization == false { - return nil - } - if let directory = snapshot.customizationDirectory { - return workspaceDirectoryCustomizationStore.directoryKey(for: directory) - } - guard snapshot.usesWorkspaceDirectoryCustomization == nil, - snapshot.remote == nil, - (snapshot.currentDirectory as NSString).isAbsolutePath else { - return nil - } - return workspaceDirectoryCustomizationStore.directoryKey(for: snapshot.currentDirectory) - } - - /// Applies authoritative sticky identity, seeding it from a snapshot only for a new directory. - func reconcileWorkspaceDirectoryCustomization( - afterRestoring snapshot: SessionWorkspaceSnapshot, - to workspace: Workspace - ) { - guard let directoryKey = workspaceCustomizationDirectory(afterRestoring: snapshot) else { - return - } - var cachedCustomizations = workspaceDirectoryCustomizationStore.customizations( - forDirectories: [directoryKey] - ) - reconcileWorkspaceDirectoryCustomization( - afterRestoring: snapshot, - to: workspace, - cachedCustomizations: &cachedCustomizations - ) - } - - /// Reconciles one restored workspace against a shared per-restore customization cache. - func reconcileWorkspaceDirectoryCustomization( - afterRestoring snapshot: SessionWorkspaceSnapshot, - to workspace: Workspace, - cachedCustomizations: inout [String: WorkspaceDirectoryCustomization] - ) { - guard let directoryKey = workspaceCustomizationDirectory(afterRestoring: snapshot) else { - return - } - let stored = cachedCustomizations[directoryKey] - let isLegacyInferredRoot = snapshot.usesWorkspaceDirectoryCustomization == nil - && snapshot.customizationDirectory == nil - guard !isLegacyInferredRoot || stored != nil else { - return - } - workspace.customizationDirectory = directoryKey - if let stored { - workspace.setCustomTitle(stored.customTitle) - workspace.setCustomColor(stored.customColor) - return - } - - let snapshotTitleIsUserOwned = snapshot.customTitle != nil - && (snapshot.customTitleSource ?? .user) == .user - guard snapshotTitleIsUserOwned || snapshot.customColor != nil else { - return - } - let seeded = workspaceDirectoryCustomizationStore.updateCustomization(for: directoryKey) { _ in - WorkspaceDirectoryCustomization( - customTitle: snapshotTitleIsUserOwned ? workspace.customTitle : nil, - customColor: snapshot.customColor != nil ? workspace.customColor : nil - ) - } - if let seeded { - cachedCustomizations[directoryKey] = seeded - } - } - - func recordWorkspaceCustomTitle( - _ workspace: Workspace, - source: Workspace.CustomTitleSource - ) { - guard source == .user, - let directory = customizationDirectory(for: workspace) else { - return - } - workspaceDirectoryCustomizationStore.setCustomTitle( - workspace.customTitle, - for: directory - ) - } - - func applyWorkspaceColor(_ color: String?, to workspaces: [Workspace]) { - guard !workspaces.isEmpty else { return } - for workspace in workspaces { - workspace.setCustomColor(color) - } - let directories = workspaces.compactMap { customizationDirectory(for: $0) } - workspaceDirectoryCustomizationStore.setCustomColor( - workspaces.first?.customColor, - forDirectories: directories - ) - } - - private func customizationDirectory(for workspace: Workspace) -> String? { - workspaceDirectoryCustomizationStore.directoryKey( - for: workspace.customizationDirectory - ) - } -} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0176c0dd6a33..7051b348a5d9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -395,7 +395,7 @@ class TabManager: ObservableObject { private let settings: any SettingsWriting private let settingsCatalog = SettingCatalog() private let defaultWorkspaceWorkingDirectoryProvider: () -> String - let workspaceDirectoryCustomizationStore: WorkspaceDirectoryCustomizationStore + let workspaceCustomizationStore: WorkspaceCustomizationStore private var lastFocusHistoryIncludesPanesAndTabs: Bool let nativeSSHConnectionBroker: NativeSSHConnectionBroker @@ -488,13 +488,13 @@ class TabManager: ObservableObject { configuredValue: GhosttyConfig.load().workingDirectory ) }, - workspaceDirectoryCustomizationStore: WorkspaceDirectoryCustomizationStore? = nil, + workspaceCustomizationStore: WorkspaceCustomizationStore? = nil, nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker(), closeTabWarningDefaults: UserDefaults = .standard ) { self.settings = settings self.defaultWorkspaceWorkingDirectoryProvider = defaultWorkspaceWorkingDirectoryProvider - self.workspaceDirectoryCustomizationStore = workspaceDirectoryCustomizationStore ?? WorkspaceDirectoryCustomizationStore() + self.workspaceCustomizationStore = workspaceCustomizationStore ?? WorkspaceCustomizationStore() let focusHistoryScopeKey = SettingCatalog().app.focusHistoryIncludesPanesAndTabs self.lastFocusHistoryIncludesPanesAndTabs = settings.value(for: focusHistoryScopeKey) self.focusHistoryNavigation = FocusHistoryModel(navigationScope: { @@ -1117,7 +1117,7 @@ class TabManager: ObservableObject { autoWelcomeIfNeeded: Bool = true, autoRefreshMetadata: Bool = true, normalizeWorkspaceGroupsAfterInsert: Bool = true, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode = .trackDirectory, + applyCreationTitleAsCustomTitle: Bool = true, allowTextBoxFocusDefault: Bool = true ) -> Workspace { let sourceWorkspace = selectedWorkspace @@ -1200,10 +1200,9 @@ class TabManager: ObservableObject { from: sourceWorkspace ?? capturedTabs.first ) newWorkspace.owningTabManager = self - if workspaceDirectoryCustomizationMode == .trackDirectory { - trackWorkspaceDirectoryCustomization( + if applyCreationTitleAsCustomTitle { + applyCreationWorkspaceCustomization( to: newWorkspace, - rootDirectory: workingDirectory, explicitTitle: title, explicitTitleSource: titleSource ) @@ -1876,7 +1875,7 @@ class TabManager: ObservableObject { initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode = .trackDirectory + applyCreationTitleAsCustomTitle: Bool = true ) -> Workspace? { workspaceGrouping.createWorkspaceInGroup( groupId: groupId, @@ -1888,7 +1887,7 @@ class TabManager: ObservableObject { initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } @@ -1990,7 +1989,7 @@ class TabManager: ObservableObject { initialBrowserTransparentBackground: Bool, inheritWorkingDirectory: Bool, select: Bool, - workspaceDirectoryCustomizationMode: WorkspaceDirectoryCustomizationCreationMode + applyCreationTitleAsCustomTitle: Bool ) -> Workspace { addWorkspace( title: title, @@ -2002,7 +2001,7 @@ class TabManager: ObservableObject { inheritWorkingDirectory: inheritWorkingDirectory, select: select, autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationMode: workspaceDirectoryCustomizationMode + applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } @@ -4308,7 +4307,7 @@ class TabManager: ObservableObject { workingDirectory: entry.snapshot.currentDirectory, select: false, autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationMode: .disabled + applyCreationTitleAsCustomTitle: false ) let restoredPanelIds = workspace.restoreSessionSnapshot(entry.snapshot, excludingStableIdentities: excludedStableIdentities) guard !entry.snapshot.hasRestorablePanels || !restoredPanelIds.isEmpty else { @@ -4319,7 +4318,7 @@ class TabManager: ObservableObject { closeWorkspace(workspace, recordHistory: false) return false } - reconcileWorkspaceDirectoryCustomization(afterRestoring: entry.snapshot, to: workspace) + reconcileWorkspaceCustomization(afterRestoring: entry.snapshot, to: workspace) // The snapshot may carry a groupId for a group that no longer exists // in this TabManager (e.g. the group was dissolved between close and // reopen). Drop those stale references so the restored workspace @@ -5735,7 +5734,6 @@ extension TabManager { hasher.combine(workspace.groupId) hasher.combine(workspace.focusedPanelId) hasher.combine(workspace.currentDirectory) - hasher.combine(workspace.customizationDirectory ?? "") hasher.combine(workspace.customTitle ?? "") hasher.combine(workspace.customDescription ?? "") hasher.combine(workspace.customColor ?? "") @@ -6140,7 +6138,12 @@ extension TabManager { ) let workspaceSnapshots = normalizedWorkspaceSnapshots .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) - var restoredDirectoryCustomizations = cachedWorkspaceDirectoryCustomizations(afterRestoring: Array(workspaceSnapshots)) + prepareLegacyWorkspaceCustomizationMigration( + afterRestoring: Array(workspaceSnapshots) + ) + let restoredCustomizations = cachedWorkspaceCustomizations( + afterRestoring: Array(workspaceSnapshots) + ) var restoredOriginalWorkspaceIds: [UUID?] = [] var reservedWorkspaceIds = excludingWorkspaceIds let identitySelector = WorkspaceSessionRestoreIdentity() @@ -6164,7 +6167,11 @@ extension TabManager { ) workspace.owningTabManager = self let restoredPanelIds = workspace.restoreSessionSnapshot(workspaceSnapshot, excludingStableIdentities: excludingStableIdentities) - reconcileWorkspaceDirectoryCustomization(afterRestoring: workspaceSnapshot, to: workspace, cachedCustomizations: &restoredDirectoryCustomizations) + reconcileWorkspaceCustomization( + afterRestoring: workspaceSnapshot, + to: workspace, + cachedCustomizations: restoredCustomizations + ) Self.recordRestoredTaskCreateProvenance(for: workspace, in: workspaceCreateIdempotencyCache) wireClosedBrowserTracking(for: workspace) newTabs.append(workspace) @@ -6187,7 +6194,6 @@ extension TabManager { nativeSSHConnectionBroker: nativeSSHConnectionBroker ) fallback.owningTabManager = self - trackWorkspaceDirectoryCustomization(to: fallback, rootDirectory: nil, explicitTitle: nil, explicitTitleSource: .auto) wireClosedBrowserTracking(for: fallback) newTabs.append(fallback) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5680cf5ab82a..bea167e1aff0 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -149,7 +149,6 @@ extension Workspace { customTitleSource: effectiveCustomTitleSource, customDescription: customDescription, customColor: customColor, - customizationDirectory: customizationDirectory, usesWorkspaceDirectoryCustomization: customizationDirectory != nil, isPinned: isPinned, groupId: groupId, isManuallyUnread: isWorkspaceManuallyUnread, @@ -2108,8 +2107,6 @@ final class Workspace: Identifiable, ObservableObject { /// The group entity itself lives in `TabManager.workspaceGroups`. @Published var groupId: UUID? @Published var customColor: String? // hex string, e.g. "#C0392B" - /// Stable directory key used for sticky user-owned title and color updates. - var customizationDirectory: String? /// User-defined environment variables applied to every shell spawned in this /// workspace: the initial terminal, every later pane/surface/split, and every /// surface recreated on session restore. Managed `CMUX_*` and terminal-identity diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ddf8d62e8d72..0c10ae0424b0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -185,7 +185,7 @@ struct cmuxApp: App { Self.applyAppearance(startupAppearance, duringLaunch: true) StartupBreadcrumbLog.append("app.init.appearance.applied", fields: ["mode": startupAppearance.rawValue]) let defaults = UserDefaults.standard - let workspaceDirectoryCustomizationStore = WorkspaceDirectoryCustomizationStore( + let workspaceCustomizationStore = WorkspaceCustomizationStore( defaults: defaults ) AppBundleIconPersistencePolicy.updateDisableDefault( @@ -196,7 +196,7 @@ struct cmuxApp: App { StartupBreadcrumbLog.append("app.init.keyboardShortcuts.sideEffectsApplied") StartupBreadcrumbLog.append("app.init.tabManager.begin") let tabManager = TabManager( - workspaceDirectoryCustomizationStore: workspaceDirectoryCustomizationStore, + workspaceCustomizationStore: workspaceCustomizationStore, nativeSSHConnectionBroker: TerminalController.shared.nativeSSHConnectionBroker ) _tabManager = StateObject(wrappedValue: tabManager) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 770d332fdb08..a15ec58d3c0b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2070,8 +2070,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */; }; C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */; }; 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000008 /* TabManager+WindowTitle.swift */; }; + CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceCustomizationPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceCustomizationPersistence.swift */; }; 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */; }; - CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceDirectoryCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceDirectoryCustomization.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; F8120F5386FBE726864D340B /* TabManagerBackgroundWorkspaceMountBoundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481E6848F8513F27E496F7BE /* TabManagerBackgroundWorkspaceMountBoundTests.swift */; }; C0DE64160000000000000001 /* TabManagerNotificationFocusRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE64160000000000000002 /* TabManagerNotificationFocusRegressionTests.swift */; }; @@ -4598,8 +4598,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SessionRestorePolicy.swift"; sourceTree = ""; }; C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SidebarGitHosting.swift"; sourceTree = ""; }; 604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = ""; }; + 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceCustomizationPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomizationPersistence.swift"; sourceTree = ""; }; 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomTitle.swift"; sourceTree = ""; }; - 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceDirectoryCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceDirectoryCustomization.swift"; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; 481E6848F8513F27E496F7BE /* TabManagerBackgroundWorkspaceMountBoundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerBackgroundWorkspaceMountBoundTests.swift; sourceTree = ""; }; C0DE64160000000000000002 /* TabManagerNotificationFocusRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerNotificationFocusRegressionTests.swift; sourceTree = ""; }; @@ -5844,7 +5844,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = DCDC1000000000000000B016 /* TabManager+BrowserFocus.swift */, C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */, 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */, - 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceDirectoryCustomization.swift */, + 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceCustomizationPersistence.swift */, F0C05170000000000000001 /* FocusHistory.swift */, C0DEFB100000000000000002 /* FocusSurfaceBroadcaster.swift */, C10D51700000000000000001 /* ClosedItemHistory.swift */, @@ -9486,8 +9486,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */, C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */, 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */, + CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceCustomizationPersistence.swift in Sources */, 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */, - CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceDirectoryCustomization.swift in Sources */, A5001003 /* TabManager.swift in Sources */, A71100000000000000000001 /* TailscaleStatusProviding.swift in Sources */, C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */, diff --git a/cmuxTests/CmuxConfigWorkspaceActionTests.swift b/cmuxTests/CmuxConfigWorkspaceActionTests.swift index 5ffc3ad73c95..7a72210f4062 100644 --- a/cmuxTests/CmuxConfigWorkspaceActionTests.swift +++ b/cmuxTests/CmuxConfigWorkspaceActionTests.swift @@ -296,15 +296,8 @@ struct CmuxConfigWorkspaceActionTests { @MainActor @Test func inlineWorkspaceActionCreatesWorkspace() throws { - let suiteName = "CmuxConfigWorkspaceActionTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = WorkspaceDirectoryCustomizationStore(defaults: defaults, storageKey: "test.customizations") - store.setCustomTitle("Sticky Label", for: NSTemporaryDirectory()) let manager = TabManager( - initialWorkingDirectory: NSTemporaryDirectory(), - workspaceDirectoryCustomizationStore: store + initialWorkingDirectory: NSTemporaryDirectory() ) let action = try #require(CmuxResolvedConfigAction.fromDefinition( id: "dev-setup", @@ -327,8 +320,6 @@ struct CmuxConfigWorkspaceActionTests { #expect(manager.tabs.count == 2) #expect(manager.selectedWorkspace?.customTitle == "Dev Setup") #expect(manager.selectedWorkspace?.effectiveCustomTitleSource == .auto) - #expect(manager.selectedWorkspace?.customizationDirectory == nil) - #expect(store.customization(for: NSTemporaryDirectory())?.customTitle == "Sticky Label") } @MainActor diff --git a/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift b/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift index 5e54fd4f4d0f..281b8f8d0c50 100644 --- a/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift +++ b/cmuxTests/WorkspaceRecoveryReviewRegressionTests.swift @@ -19,12 +19,12 @@ struct WorkspaceRecoveryReviewRegressionTests { defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } let directory = "/tmp/shared-workspace-customization" let snapshots = try distinctWorkspaceSnapshots(in: directory) - fixture.store.setCustomTitle("Directory Label", for: directory) - fixture.store.setCustomColor("#ABCDEF", for: directory) + fixture.legacyStore.setCustomTitle("Directory Label", for: directory) + fixture.legacyStore.setCustomColor("#ABCDEF", for: directory) let restoredManager = TabManager( autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store + workspaceCustomizationStore: fixture.store ) restoredManager.restoreSessionSnapshot(SessionTabManagerSnapshot( selectedWorkspaceIndex: 0, @@ -50,11 +50,11 @@ struct WorkspaceRecoveryReviewRegressionTests { defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } let directory = "/tmp/shared-closed-workspace-customization" let snapshots = try distinctWorkspaceSnapshots(in: directory) - fixture.store.setCustomTitle("Directory Label", for: directory) - fixture.store.setCustomColor("#ABCDEF", for: directory) + fixture.legacyStore.setCustomTitle("Directory Label", for: directory) + fixture.legacyStore.setCustomColor("#ABCDEF", for: directory) let restoredManager = TabManager( autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store + workspaceCustomizationStore: fixture.store ) for (index, snapshot) in snapshots.enumerated() { @@ -79,175 +79,6 @@ struct WorkspaceRecoveryReviewRegressionTests { #expect(second.customColor == "#445566") } - @Test - func generatedProWorkspaceDoesNotOverwriteStickyProjectIdentity() throws { - _ = NSApplication.shared - let browserDefaults = UserDefaults.standard - let previousBrowserDisabled = browserDefaults.object( - forKey: BrowserAvailabilitySettings.disabledKey - ) - BrowserAvailabilitySettings.setDisabled(false) - defer { - if let previousBrowserDisabled { - browserDefaults.set( - previousBrowserDisabled, - forKey: BrowserAvailabilitySettings.disabledKey - ) - } else { - browserDefaults.removeObject(forKey: BrowserAvailabilitySettings.disabledKey) - NotificationCenter.default.post( - name: BrowserAvailabilitySettings.didChangeNotification, - object: nil - ) - } - } - - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let directory = "/tmp/pro-workspace-customization" - fixture.store.setCustomTitle("Project Label", for: directory) - fixture.store.setCustomColor("#123456", for: directory) - - let previousAppDelegate = AppDelegate.shared - let appDelegate = AppDelegate() - AppDelegate.shared = appDelegate - defer { AppDelegate.shared = previousAppDelegate } - - let manager = TabManager( - initialWorkingDirectory: directory, - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store - ) - let windowId = UUID() - let window = makeMainWindow(id: windowId) - appDelegate.registerMainWindow( - window, - windowId: windowId, - tabManager: manager, - sidebarState: SidebarState(), - sidebarSelectionState: SidebarSelectionState(), - fileExplorerState: FileExplorerState() - ) - defer { - appDelegate.unregisterMainWindowContextForTesting(windowId: windowId) - window.orderOut(nil) - } - let pricingURL = try #require(URL(string: "https://cmux.com/app-pricing?cmux_app=1")) - - let proWorkspace = try #require(appDelegate.performProUpgradeWorkspaceAction( - title: "cmux Pro", - url: pricingURL, - tabManager: manager - )) - - #expect(proWorkspace.title == "cmux Pro") - #expect(proWorkspace.customizationDirectory == nil) - #expect(proWorkspace.customColor == nil) - #expect( - fixture.store.customization(for: directory) == - WorkspaceDirectoryCustomization( - customTitle: "Project Label", - customColor: "#123456" - ) - ) - } - - @Test - func legacyLocalSnapshotInfersItsStickyCustomizationRoot() throws { - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let directory = "/tmp/legacy-sticky-project" - fixture.store.setCustomTitle("Current Label", for: directory) - fixture.store.setCustomColor("#778899", for: directory) - - let sourceManager = TabManager( - initialWorkingDirectory: directory, - autoWelcomeIfNeeded: false - ) - let sourceWorkspace = try #require(sourceManager.selectedWorkspace) - sourceWorkspace.setCustomTitle("Legacy Snapshot Label") - sourceWorkspace.setCustomColor("#111111") - var snapshot = sourceManager.sessionSnapshot(includeScrollback: false) - snapshot.workspaces[0].customizationDirectory = nil - snapshot.workspaces[0].usesWorkspaceDirectoryCustomization = nil - - let restoredManager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store - ) - restoredManager.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try #require(restoredManager.selectedWorkspace) - #expect(restoredWorkspace.customTitle == "Current Label") - #expect(restoredWorkspace.customColor == "#778899") - #expect( - restoredWorkspace.customizationDirectory == - fixture.store.directoryKey(for: directory) - ) - } - - @Test - func explicitlyIneligibleSnapshotDoesNotAdoptStickyProjectIdentity() throws { - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let directory = "/tmp/ineligible-sticky-project" - fixture.store.setCustomTitle("Project Label", for: directory) - - let sourceManager = TabManager( - initialWorkingDirectory: directory, - autoWelcomeIfNeeded: false - ) - let sourceWorkspace = try #require(sourceManager.selectedWorkspace) - sourceWorkspace.setCustomTitle("Generated Workspace") - var snapshot = sourceManager.sessionSnapshot(includeScrollback: false) - snapshot.workspaces[0].customizationDirectory = nil - snapshot.workspaces[0].usesWorkspaceDirectoryCustomization = false - - let restoredManager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store - ) - restoredManager.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try #require(restoredManager.selectedWorkspace) - #expect(restoredWorkspace.customTitle == "Generated Workspace") - #expect(restoredWorkspace.customizationDirectory == nil) - #expect(restoredManager.setCustomTitle( - tabId: restoredWorkspace.id, - title: "Later Generated Rename" - )) - #expect(fixture.store.customization(for: directory)?.customTitle == "Project Label") - } - - @Test - func legacyGeneratedSnapshotCannotSeedStickyProjectIdentity() throws { - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let directory = "/tmp/legacy-generated-workspace" - - let sourceManager = TabManager( - initialWorkingDirectory: directory, - autoWelcomeIfNeeded: false - ) - let sourceWorkspace = try #require(sourceManager.selectedWorkspace) - sourceWorkspace.setCustomTitle("cmux Pro") - sourceWorkspace.setCustomColor("#111111") - var snapshot = sourceManager.sessionSnapshot(includeScrollback: false) - snapshot.workspaces[0].customizationDirectory = nil - snapshot.workspaces[0].usesWorkspaceDirectoryCustomization = nil - - let restoredManager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store - ) - restoredManager.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try #require(restoredManager.selectedWorkspace) - #expect(restoredWorkspace.customTitle == "cmux Pro") - #expect(restoredWorkspace.customizationDirectory == nil) - #expect(fixture.store.customization(for: directory) == nil) - } - @Test func loadTimeWorkspaceCapacityTrimIsPersisted() async throws { let temporaryDirectory = FileManager.default.temporaryDirectory @@ -292,34 +123,30 @@ struct WorkspaceRecoveryReviewRegressionTests { } private func makeCustomizationStore() throws -> ( - store: WorkspaceDirectoryCustomizationStore, + store: WorkspaceCustomizationStore, + legacyStore: WorkspaceDirectoryCustomizationStore, defaults: UserDefaults, suiteName: String ) { - let suiteName = "WorkspaceDirectoryCustomizationStore.\(UUID().uuidString)" + let suiteName = "WorkspaceCustomizationStore.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) + let legacyStorageKey = "test.legacy-customizations" return ( + WorkspaceCustomizationStore( + defaults: defaults, + storageKey: "test.customizations", + legacyStorageKey: legacyStorageKey + ), WorkspaceDirectoryCustomizationStore( defaults: defaults, - storageKey: "test.customizations" + storageKey: legacyStorageKey ), defaults, suiteName ) } - private func makeMainWindow(id: UUID) -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") - return window - } - private func distinctWorkspaceSnapshots( in directory: String ) throws -> [SessionWorkspaceSnapshot] { diff --git a/cmuxTests/WorkspaceRecoveryTests.swift b/cmuxTests/WorkspaceRecoveryTests.swift index fe8cf4b412ae..d8b39120bee8 100644 --- a/cmuxTests/WorkspaceRecoveryTests.swift +++ b/cmuxTests/WorkspaceRecoveryTests.swift @@ -12,17 +12,18 @@ import Testing @Suite(.serialized) struct WorkspaceRecoveryTests { private func makeCustomizationStore() throws -> ( - store: WorkspaceDirectoryCustomizationStore, + store: WorkspaceCustomizationStore, defaults: UserDefaults, suiteName: String ) { - let suiteName = "WorkspaceDirectoryCustomizationStore.\(UUID().uuidString)" + let suiteName = "WorkspaceCustomizationStore.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) return ( - WorkspaceDirectoryCustomizationStore( + WorkspaceCustomizationStore( defaults: defaults, - storageKey: "test.customizations" + storageKey: "test.customizations", + legacyStorageKey: "test.legacy-customizations" ), defaults, suiteName @@ -213,11 +214,10 @@ struct WorkspaceRecoveryTests { } @Test - func closedRestoreDoesNotTurnAnAutomaticSnapshotTitleIntoStickyUserIdentity() throws { + func closedRestoreKeepsAutomaticSnapshotTitleProvenance() throws { let directory = "/tmp/automatic-history-title" let fixture = try makeCustomizationStore() defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - fixture.store.setCustomTitle("Sticky Label", for: directory) let sourceManager = TabManager( initialWorkingDirectory: directory, @@ -238,30 +238,23 @@ struct WorkspaceRecoveryTests { let destinationManager = TabManager( autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store + workspaceCustomizationStore: fixture.store ) let historyStore = ClosedItemHistoryStore(loadPersisted: false) historyStore.push(.workspace(entry)) #expect(destinationManager.reopenMostRecentlyClosedWorkspace(from: historyStore)) - #expect(destinationManager.selectedWorkspace?.customTitle == "Sticky Label") - #expect(fixture.store.customization(for: directory)?.customTitle == "Sticky Label") - - let generated = destinationManager.addWorkspace( - title: "Generated Title", - titleSource: .auto, - workingDirectory: directory, - select: false - ) - #expect(generated.customTitle == "Generated Title") - #expect(fixture.store.customization(for: directory)?.customTitle == "Sticky Label") + let reopened = try #require(destinationManager.selectedWorkspace) + #expect(reopened.customTitle == "Automatic Snapshot Title") + #expect(reopened.effectiveCustomTitleSource == .auto) + #expect(fixture.store.customization(for: reopened.stableId) == nil) } + @Test - func failedClosedRestoreDoesNotPersistSnapshotCustomization() throws { + func failedClosedRestoreLeavesNoWorkspaceOrRecoveryRecord() throws { let directory = "/tmp/failed-history-restore" let fixture = try makeCustomizationStore() defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - fixture.store.setCustomTitle("Existing Label", for: directory) let sourceManager = TabManager(initialWorkingDirectory: directory, autoWelcomeIfNeeded: false) var snapshot = try #require(sourceManager.selectedWorkspace).sessionSnapshot(includeScrollback: false) @@ -287,56 +280,19 @@ struct WorkspaceRecoveryTests { ) let destinationManager = TabManager( autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: fixture.store + workspaceCustomizationStore: fixture.store ) + let tabsBeforeRestore = destinationManager.tabs.map(\.id) #expect(!destinationManager.restoreClosedWorkspace(entry)) - #expect(fixture.store.customization(for: directory)?.customTitle == "Existing Label") - let defaultDirectory = "/tmp/failed-history-default-root" - fixture.store.setCustomTitle("Home Label", for: defaultDirectory) - let rootlessManager = TabManager( - autoWelcomeIfNeeded: false, - defaultWorkspaceWorkingDirectoryProvider: { defaultDirectory }, - workspaceDirectoryCustomizationStore: fixture.store - ) - let rootlessWorkspace = try #require(rootlessManager.selectedWorkspace) - #expect(rootlessWorkspace.customTitle == nil) - rootlessManager.setTabColor(tabId: rootlessWorkspace.id, color: "#123456") - #expect( - fixture.store.customization(for: defaultDirectory) == - WorkspaceDirectoryCustomization(customTitle: "Home Label", customColor: "#123456") - ) - } - - @Test - func directoryCustomizationPersistsAndNormalizesEquivalentPaths() throws { - let suiteName = "WorkspaceDirectoryCustomizationStore.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let firstStore = WorkspaceDirectoryCustomizationStore( - defaults: defaults, - storageKey: "test.customizations" - ) - firstStore.setCustomTitle("Project Alpha", for: "/tmp/project/../project") - firstStore.setCustomColor("#123456", for: "/tmp/project") - - let reloadedStore = WorkspaceDirectoryCustomizationStore( - defaults: defaults, - storageKey: "test.customizations" - ) - #expect( - reloadedStore.customization(for: "/tmp/project/") == - WorkspaceDirectoryCustomization( - customTitle: "Project Alpha", - customColor: "#123456" - ) - ) + #expect(destinationManager.tabs.map(\.id) == tabsBeforeRestore) + if let stableId = snapshot.stableId { + #expect(fixture.store.customization(for: stableId) == nil) + } } @Test - func freshWorkspaceCreationDoesNotAdoptStickyDirectoryIdentityFromInheritedCwd() throws { + func freshWorkspaceCreationDoesNotCloneRenamedSiblingIdentity() throws { let directoryURL = FileManager.default.temporaryDirectory .appending(path: "cmux-sticky-cmdn-\(UUID().uuidString)", directoryHint: .isDirectory) try FileManager.default.createDirectory( @@ -351,7 +307,7 @@ struct WorkspaceRecoveryTests { let manager = TabManager( initialWorkingDirectory: directory, autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store + workspaceCustomizationStore: store ) let sourceWorkspace = try #require(manager.selectedWorkspace) @@ -363,13 +319,6 @@ struct WorkspaceRecoveryTests { "#AABBCC", toWorkspaceIds: [sourceWorkspace.id] ) - #expect( - store.customization(for: directory) == - WorkspaceDirectoryCustomization( - customTitle: "MY WORKSPACE", - customColor: "#AABBCC" - ) - ) let generated = manager.addWorkspace(select: false) #expect(generated.title == "Terminal 2") @@ -388,183 +337,90 @@ struct WorkspaceRecoveryTests { } @Test - func createRenameAndColorChangesShareOneStickyDirectoryRecord() throws { - let directory = "/tmp/sticky-project" + func stableWorkspaceJournalRecoversStaleTitleColorAndClears() throws { let fixture = try makeCustomizationStore() defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } let store = fixture.store - store.setCustomTitle("Original Label", for: directory) - store.setCustomColor("#112233", for: directory) - let firstManager = TabManager( - initialWorkingDirectory: "\(directory)/.", + let sourceManager = TabManager( + initialWorkingDirectory: "/tmp/stable-workspace-journal", autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store + workspaceCustomizationStore: store ) - let firstWorkspace = try #require(firstManager.selectedWorkspace) - #expect(firstWorkspace.customTitle == nil) - #expect(firstWorkspace.customColor == nil) - #expect(firstWorkspace.customizationDirectory == store.directoryKey(for: directory)) - - firstWorkspace.currentDirectory = "/tmp/sticky-project/subdirectory" - #expect(firstManager.setCustomTitle( - tabId: firstWorkspace.id, + let sourceWorkspace = try #require(sourceManager.selectedWorkspace) + #expect(sourceManager.setCustomTitle( + tabId: sourceWorkspace.id, title: "Renamed Label" )) - firstManager.setTabColor(tabId: firstWorkspace.id, color: "#AABBCC") + sourceManager.setTabColor(tabId: sourceWorkspace.id, color: "#AABBCC") #expect( - store.customization(for: directory) == - WorkspaceDirectoryCustomization( - customTitle: "Renamed Label", - customColor: "#AABBCC" + store.customization(for: sourceWorkspace.stableId) == + WorkspaceCustomization( + customTitle: .value("Renamed Label"), + customColor: .value("#AABBCC") ) ) - var staleSnapshot = firstWorkspace.sessionSnapshot(includeScrollback: false) + var staleSnapshot = sourceWorkspace.sessionSnapshot(includeScrollback: false) staleSnapshot.customTitle = "Stale Snapshot Label" staleSnapshot.customColor = "#112233" - let secondManager = TabManager( + let restoredManager = TabManager( autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store + workspaceCustomizationStore: store ) - secondManager.restoreSessionSnapshot(SessionTabManagerSnapshot( + restoredManager.restoreSessionSnapshot(SessionTabManagerSnapshot( selectedWorkspaceIndex: 0, workspaces: [staleSnapshot] )) - let secondWorkspace = try #require(secondManager.selectedWorkspace) - #expect(secondWorkspace.customTitle == "Renamed Label") - #expect(secondWorkspace.customColor == "#AABBCC") - #expect(store.customization(for: firstWorkspace.currentDirectory) == nil) + let restoredWorkspace = try #require(restoredManager.selectedWorkspace) + #expect(restoredWorkspace.customTitle == "Renamed Label") + #expect(restoredWorkspace.customColor == "#AABBCC") - secondManager.clearCustomTitle(tabId: secondWorkspace.id) - secondManager.setTabColor(tabId: secondWorkspace.id, color: nil) + restoredManager.clearCustomTitle(tabId: restoredWorkspace.id) + restoredManager.setTabColor(tabId: restoredWorkspace.id, color: nil) + var staleClearedSnapshot = restoredWorkspace.sessionSnapshot(includeScrollback: false) + staleClearedSnapshot.customTitle = "Resurrected Label" + staleClearedSnapshot.customColor = "#FFFFFF" - let clearedManager = TabManager( - initialWorkingDirectory: directory, - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store - ) + let clearedManager = TabManager(autoWelcomeIfNeeded: false, workspaceCustomizationStore: store) clearedManager.restoreSessionSnapshot(SessionTabManagerSnapshot( selectedWorkspaceIndex: 0, - workspaces: [secondWorkspace.sessionSnapshot(includeScrollback: false)] + workspaces: [staleClearedSnapshot] )) let clearedWorkspace = try #require(clearedManager.selectedWorkspace) #expect(clearedWorkspace.customTitle == nil) #expect(clearedWorkspace.customColor == nil) - - #expect(clearedManager.setCustomTitle( - tabId: clearedWorkspace.id, - title: "Automatic Title", - source: .auto - )) - let afterAutomaticRename = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store - ) - afterAutomaticRename.restoreSessionSnapshot(SessionTabManagerSnapshot( - selectedWorkspaceIndex: 0, - workspaces: [clearedWorkspace.sessionSnapshot(includeScrollback: false)] - )) - #expect(afterAutomaticRename.selectedWorkspace?.customTitle == nil) } @Test - func batchColorChangesPersistForEveryWorkspaceRoot() throws { + func titleAndColorRecoveryFieldsRemainIndependent() throws { let fixture = try makeCustomizationStore() defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } let store = fixture.store - store.setCustomTitle("First", for: "/tmp/batch-first") - store.setCustomTitle("Second", for: "/tmp/batch-second") let manager = TabManager( - initialWorkingDirectory: "/tmp/batch-first", + initialWorkingDirectory: "/tmp/independent-fields", autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store + workspaceCustomizationStore: store ) - let first = try #require(manager.selectedWorkspace) - let second = manager.addWorkspace( - workingDirectory: "/tmp/batch-second", - select: false - ) - - manager.applyWorkspaceColor( - "#123456", - toWorkspaceIds: [first.id, second.id] - ) - - #expect(store.customization(for: "/tmp/batch-first")?.customTitle == "First") - #expect(store.customization(for: "/tmp/batch-first")?.customColor == "#123456") - #expect(store.customization(for: "/tmp/batch-second")?.customTitle == "Second") - #expect(store.customization(for: "/tmp/batch-second")?.customColor == "#123456") - } - - @Test - func sessionRestoreAppliesStickyCustomizationToTheWorkspaceRoot() throws { - let directory = "/tmp/session-sticky-project" - let sourceManager = TabManager(initialWorkingDirectory: directory, autoWelcomeIfNeeded: false) - let sourceWorkspace = try #require(sourceManager.selectedWorkspace) - #expect(sourceManager.setCustomTitle(tabId: sourceWorkspace.id, title: "Stale Snapshot Label")) - sourceManager.setTabColor(tabId: sourceWorkspace.id, color: "#111111") - let snapshot = sourceManager.sessionSnapshot(includeScrollback: false) - #expect(snapshot.workspaces.first?.customizationDirectory == directory) - - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let store = fixture.store - store.setCustomTitle("Sticky Session Label", for: directory) - store.setCustomColor("#778899", for: directory) - let restoredManager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store - ) - - restoredManager.restoreSessionSnapshot(snapshot) - - let restoredWorkspace = try #require(restoredManager.selectedWorkspace) - #expect(restoredWorkspace.customTitle == "Sticky Session Label") - #expect(restoredWorkspace.customColor == "#778899") - #expect(restoredWorkspace.customizationDirectory == store.directoryKey(for: directory)) - } - - @Test - func explicitCreationTitleUpdatesStickyLabelAndPreservesStickyColor() throws { - let directory = "/tmp/explicit-project" - let fixture = try makeCustomizationStore() - defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) } - let store = fixture.store - store.setCustomTitle("Old Label", for: directory) - store.setCustomColor("#445566", for: directory) - let manager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store - ) - - let explicitlyNamed = manager.addWorkspace( - title: "CLI Label", - workingDirectory: directory, - inheritWorkingDirectory: false, - select: false - ) - #expect(explicitlyNamed.customTitle == "CLI Label") - #expect(explicitlyNamed.customColor == nil) + let workspace = try #require(manager.selectedWorkspace) + workspace.setCustomColor("#123456") + #expect(manager.setCustomTitle(tabId: workspace.id, title: "Only Title Recorded")) #expect( - store.customization(for: directory) == - WorkspaceDirectoryCustomization( - customTitle: "CLI Label", - customColor: "#445566" + store.customization(for: workspace.stableId) == + WorkspaceCustomization( + customTitle: .value("Only Title Recorded"), + customColor: .absent ) ) - let laterManager = TabManager( - autoWelcomeIfNeeded: false, - workspaceDirectoryCustomizationStore: store - ) - laterManager.restoreSessionSnapshot(SessionTabManagerSnapshot( + let restoredManager = TabManager(autoWelcomeIfNeeded: false, workspaceCustomizationStore: store) + restoredManager.restoreSessionSnapshot(SessionTabManagerSnapshot( selectedWorkspaceIndex: 0, - workspaces: [explicitlyNamed.sessionSnapshot(includeScrollback: false)] + workspaces: [workspace.sessionSnapshot(includeScrollback: false)] )) - #expect(laterManager.selectedWorkspace?.customTitle == "CLI Label") - #expect(laterManager.selectedWorkspace?.customColor == "#445566") + #expect(restoredManager.selectedWorkspace?.customTitle == "Only Title Recorded") + #expect(restoredManager.selectedWorkspace?.customColor == "#123456") } }