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
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ public final class WorkspaceGroupCoordinator<Tab: WorkspaceTabRepresenting> {

/// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public protocol WorkspaceGroupHosting<Tab>: 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?,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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) })
}
}
Original file line number Diff line number Diff line change
@@ -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<String>,
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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// 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?

/// The explicit user-owned workspace accent color.
public let customColor: String?

/// Creates a directory customization.
///
/// - Parameters:
/// - customTitle: The explicit workspace label, or `nil` when unset.
/// - customColor: The explicit workspace color, or `nil` when unset.
public init(customTitle: String?, customColor: String?) {
self.customTitle = customTitle
self.customColor = customColor
}

/// Whether neither user-owned field is set.
public var isEmpty: Bool {
customTitle == nil && customColor == nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

/// One legacy directory customization with its mutation-recency revision.
struct WorkspaceDirectoryCustomizationPersistenceEntry: Codable, Equatable, Sendable {
let customization: WorkspaceDirectoryCustomization
let revision: UInt64
}
Loading