From 4b444812bed26eeafec6359566f7d4f5d09ebb9b Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 24 Apr 2026 23:42:13 -0700 Subject: [PATCH 1/4] Add cmux.json named color regression test --- cmuxTests/CmuxConfigTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 736e24e89e03..a4b8a1097d42 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -1044,6 +1044,22 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(ws?.color, "#FF5733") } + func testDecodeWorkspaceCommandAcceptsNamedColor() throws { + let json = """ + { + "commands": [{ + "name": "Dev env", + "workspace": { + "name": "Development", + "color": "Indigo" + } + }] + } + """ + let config = try decode(json) + XCTAssertEqual(config.commands[0].workspace?.color, "#283593") + } + func testDecodeRestartBehaviors() throws { for behavior in ["recreate", "ignore", "confirm"] { let json = """ From 4c55836ee437dbfb9b3b1dfa3a5bc1c068e3b5a8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 24 Apr 2026 23:50:33 -0700 Subject: [PATCH 2/4] Accept named colors in cmux.json --- Sources/CmuxConfig.swift | 4 ++-- Sources/TabManager.swift | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 8b442a7e4b62..68a12f6e0dd6 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -1606,11 +1606,11 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { - guard let normalized = WorkspaceTabColorSettings.normalizedHex(rawColor) else { + guard let normalized = WorkspaceTabColorSettings.resolvedColorHex(rawColor) else { throw DecodingError.dataCorruptedError( forKey: .color, in: container, - debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format: #RRGGBB" + debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format (#RRGGBB) or a workspace color name" ) } color = normalized diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 89dcfac53a8a..d4c9a65f15a4 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -300,6 +300,18 @@ enum WorkspaceTabColorSettings { effectivePaletteMap(defaults: defaults)[name] } + static func resolvedColorHex(_ raw: String, defaults: UserDefaults = .standard) -> String? { + if let normalized = normalizedHex(raw) { + return normalized + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return effectivePaletteMap(defaults: defaults) + .first { name, _ in name.caseInsensitiveCompare(trimmed) == .orderedSame }? + .value + } + static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) { guard let normalizedName = normalizedColorName(name), let normalizedHex = normalizedHex(hex) else { return } From d11eb4d09be7bc714ca78e27a5162658d207da07 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 20:38:41 -0700 Subject: [PATCH 3/4] Invalidate cmux config color cache on palette changes --- GhosttyTabs.xcodeproj/project.pbxproj | 12 +++ Sources/CmuxConfig.swift | 46 +++-------- Sources/CmuxWorkspaceDefinition.swift | 36 +++++++++ Sources/TabManager.swift | 12 --- Sources/WorkspaceTabColorResolution.swift | 22 ++++++ cmuxTests/CmuxConfigNamedColorTests.swift | 96 +++++++++++++++++++++++ cmuxTests/CmuxConfigTests.swift | 16 ---- 7 files changed, 177 insertions(+), 63 deletions(-) create mode 100644 Sources/CmuxWorkspaceDefinition.swift create mode 100644 Sources/WorkspaceTabColorResolution.swift create mode 100644 cmuxTests/CmuxConfigNamedColorTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ceb499f0f902..5673f42b3916 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 2F0C05000000000000000002 /* MainWindowFocusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C05000000000000000001 /* MainWindowFocusController.swift */; }; A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; + E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000001 /* WorkspaceTabColorResolution.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; @@ -107,6 +108,7 @@ A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; + E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */; }; A5001652 /* CmuxConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* CmuxConfigExecutor.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; @@ -142,6 +144,7 @@ B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */; }; + E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; @@ -299,6 +302,7 @@ C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MenuBarExtraController.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; + E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; @@ -355,6 +359,7 @@ A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; + E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWorkspaceDefinition.swift; sourceTree = ""; }; A5001653 /* CmuxConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigExecutor.swift; sourceTree = ""; }; A5001655 /* CmuxActionTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxActionTrust.swift; sourceTree = ""; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; @@ -389,6 +394,7 @@ BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigTests.swift; sourceTree = ""; }; + E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfigNamedColorTests.swift; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Resources/bin/claude; sourceTree = SOURCE_ROOT; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; @@ -595,6 +601,7 @@ A5F10010A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift */, A50012F4 /* KeyboardLayout.swift */, A5001013 /* TabManager.swift */, + E30750000000000000000001 /* WorkspaceTabColorResolution.swift */, A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, A5001416 /* Workspace.swift */, @@ -650,6 +657,7 @@ A5001661 /* RestorableAgentSession.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* CmuxConfig.swift */, + E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */, A5001653 /* CmuxConfigExecutor.swift */, A5001655 /* CmuxActionTrust.swift */, FE001001 /* FileExplorerStore.swift */, @@ -721,6 +729,7 @@ 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, + E30750000000000000000005 /* CmuxConfigNamedColorTests.swift */, FE002001 /* FileExplorerRootResolverTests.swift */, FE002002 /* FileExplorerStoreTests.swift */, ); @@ -984,6 +993,7 @@ A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */, A50012F5 /* KeyboardLayout.swift in Sources */, A5001003 /* TabManager.swift in Sources */, + E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */, A5001501 /* UITestRecorder.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, A5001406 /* Workspace.swift in Sources */, @@ -1038,6 +1048,7 @@ A5001660 /* RestorableAgentSession.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, + E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */, A5001652 /* CmuxConfigExecutor.swift in Sources */, A5001654 /* CmuxActionTrust.swift in Sources */, FE001101 /* FileExplorerStore.swift in Sources */, @@ -1140,6 +1151,7 @@ 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 2BB56A710BB1FC50367E5BCF /* TabManagerSessionSnapshotTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, + E30750000000000000000006 /* CmuxConfigNamedColorTests.swift in Sources */, FE002101 /* FileExplorerRootResolverTests.swift in Sources */, FE002102 /* FileExplorerStoreTests.swift in Sources */, ); diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 68a12f6e0dd6..fec499f0a916 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -3,6 +3,10 @@ import Combine import CryptoKit import Foundation +extension CodingUserInfoKey { + static let cmuxWorkspaceColorDefaults = CodingUserInfoKey(rawValue: "cmuxWorkspaceColorDefaults")! +} + struct CmuxConfigFile: Codable, Sendable { var actions: [String: CmuxConfigActionDefinition] var ui: CmuxConfigUIDefinition? @@ -1586,40 +1590,6 @@ enum CmuxRestartBehavior: String, Codable, Sendable { case confirm } -struct CmuxWorkspaceDefinition: Codable, Sendable { - var name: String? - var cwd: String? - var color: String? - var layout: CmuxLayoutNode? - - init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { - self.name = name - self.cwd = cwd - self.color = color - self.layout = layout - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decodeIfPresent(String.self, forKey: .name) - cwd = try container.decodeIfPresent(String.self, forKey: .cwd) - layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) - - if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { - guard let normalized = WorkspaceTabColorSettings.resolvedColorHex(rawColor) else { - throw DecodingError.dataCorruptedError( - forKey: .color, - in: container, - debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format (#RRGGBB) or a workspace color name" - ) - } - color = normalized - } else { - color = nil - } - } -} - indirect enum CmuxLayoutNode: Codable, Sendable { case pane(CmuxPaneDefinition) case split(CmuxSplitDefinition) @@ -1858,6 +1828,7 @@ final class CmuxConfigStore: ObservableObject { private struct ParsedConfigCacheEntry { let fileSize: UInt64 let modificationDate: Date? + let workspaceColorPaletteFingerprint: String let config: CmuxConfigFile? let issue: CmuxConfigIssue? } @@ -2512,10 +2483,12 @@ final class CmuxConfigStore: ObservableObject { let attributes = try? fileManager.attributesOfItem(atPath: path) let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 let modificationDate = attributes?[.modificationDate] as? Date + let paletteFingerprint = WorkspaceTabColorSettings.paletteCacheFingerprint() if let cached = parsedConfigCache[path], cached.fileSize == fileSize, - cached.modificationDate == modificationDate { + cached.modificationDate == modificationDate, + cached.workspaceColorPaletteFingerprint == paletteFingerprint { return ParsedConfigResult(config: cached.config, issue: cached.issue) } @@ -2525,6 +2498,7 @@ final class CmuxConfigStore: ObservableObject { parsedConfigCache[path] = ParsedConfigCacheEntry( fileSize: fileSize, modificationDate: modificationDate, + workspaceColorPaletteFingerprint: paletteFingerprint, config: nil, issue: issue ) @@ -2535,6 +2509,7 @@ final class CmuxConfigStore: ObservableObject { parsedConfigCache[path] = ParsedConfigCacheEntry( fileSize: fileSize, modificationDate: modificationDate, + workspaceColorPaletteFingerprint: paletteFingerprint, config: config, issue: nil ) @@ -2544,6 +2519,7 @@ final class CmuxConfigStore: ObservableObject { parsedConfigCache[path] = ParsedConfigCacheEntry( fileSize: fileSize, modificationDate: modificationDate, + workspaceColorPaletteFingerprint: paletteFingerprint, config: nil, issue: issue ) diff --git a/Sources/CmuxWorkspaceDefinition.swift b/Sources/CmuxWorkspaceDefinition.swift new file mode 100644 index 000000000000..9ec0c810715b --- /dev/null +++ b/Sources/CmuxWorkspaceDefinition.swift @@ -0,0 +1,36 @@ +import Foundation + +struct CmuxWorkspaceDefinition: Codable, Sendable { + var name: String? + var cwd: String? + var color: String? + var layout: CmuxLayoutNode? + + init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { + self.name = name + self.cwd = cwd + self.color = color + self.layout = layout + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) + + if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { + let defaults = decoder.userInfo[.cmuxWorkspaceColorDefaults] as? UserDefaults ?? .standard + guard let normalized = WorkspaceTabColorSettings.resolvedColorHex(rawColor, defaults: defaults) else { + throw DecodingError.dataCorruptedError( + forKey: .color, + in: container, + debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format (#RRGGBB) or a workspace color name" + ) + } + color = normalized + } else { + color = nil + } + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index c5964b975f60..8cfb3f569894 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -300,18 +300,6 @@ enum WorkspaceTabColorSettings { effectivePaletteMap(defaults: defaults)[name] } - static func resolvedColorHex(_ raw: String, defaults: UserDefaults = .standard) -> String? { - if let normalized = normalizedHex(raw) { - return normalized - } - - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return effectivePaletteMap(defaults: defaults) - .first { name, _ in name.caseInsensitiveCompare(trimmed) == .orderedSame }? - .value - } - static func setColor(named name: String, hex: String, defaults: UserDefaults = .standard) { guard let normalizedName = normalizedColorName(name), let normalizedHex = normalizedHex(hex) else { return } diff --git a/Sources/WorkspaceTabColorResolution.swift b/Sources/WorkspaceTabColorResolution.swift new file mode 100644 index 000000000000..8838ebb7a294 --- /dev/null +++ b/Sources/WorkspaceTabColorResolution.swift @@ -0,0 +1,22 @@ +import Foundation + +extension WorkspaceTabColorSettings { + static func resolvedColorHex(_ raw: String, defaults: UserDefaults = .standard) -> String? { + if let normalized = normalizedHex(raw) { + return normalized + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return resolvedPaletteMap(defaults: defaults) + .first { name, _ in name.caseInsensitiveCompare(trimmed) == .orderedSame }? + .value + } + + static func paletteCacheFingerprint(defaults: UserDefaults = .standard) -> String { + resolvedPaletteMap(defaults: defaults) + .sorted { lhs, rhs in lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending } + .map { "\($0.key)=\($0.value)" } + .joined(separator: "\n") + } +} diff --git a/cmuxTests/CmuxConfigNamedColorTests.swift b/cmuxTests/CmuxConfigNamedColorTests.swift new file mode 100644 index 000000000000..2da29fbd8701 --- /dev/null +++ b/cmuxTests/CmuxConfigNamedColorTests.swift @@ -0,0 +1,96 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CmuxConfigNamedColorTests: XCTestCase { + private func decode(_ json: String, colorDefaults: UserDefaults? = nil) throws -> CmuxConfigFile { + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + if let colorDefaults { + decoder.userInfo[.cmuxWorkspaceColorDefaults] = colorDefaults + } + return try decoder.decode(CmuxConfigFile.self, from: data) + } + + func testDecodeWorkspaceCommandAcceptsNamedColor() throws { + let suiteName = "cmux-config-named-color-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + WorkspaceTabColorSettings.persistPaletteMap(["Indigo": "#283593"], defaults: defaults) + + let json = """ + { + "commands": [{ + "name": "Dev env", + "workspace": { + "name": "Development", + "color": "Indigo" + } + }] + } + """ + let config = try decode(json, colorDefaults: defaults) + XCTAssertEqual(config.commands[0].workspace?.color, "#283593") + } + + func testDecodeWorkspaceCommandRejectsUnknownNamedColor() { + let json = """ + { + "commands": [{ + "name": "Dev env", + "workspace": { + "name": "Development", + "color": "Definitely Not A Palette Color" + } + }] + } + """ + XCTAssertThrowsError(try decode(json)) + } + + @MainActor + func testConfigParseCacheInvalidatesWhenWorkspaceColorPaletteChanges() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-config-store-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let previousPalette = UserDefaults.standard.dictionary(forKey: WorkspaceTabColorSettings.paletteKey) + defer { + if let previousPalette { + UserDefaults.standard.set(previousPalette, forKey: WorkspaceTabColorSettings.paletteKey) + } else { + UserDefaults.standard.removeObject(forKey: WorkspaceTabColorSettings.paletteKey) + } + } + + let configURL = root.appendingPathComponent("cmux.json") + let json = """ + { + "commands": [{ + "name": "Dev env", + "workspace": { + "name": "Development", + "color": "Codex Test" + } + }] + } + """ + try json.write(to: configURL, atomically: true, encoding: .utf8) + + let store = CmuxConfigStore(globalConfigPath: configURL.path, startFileWatchers: false) + WorkspaceTabColorSettings.persistPaletteMap(["Codex Test": "#111111"]) + store.loadAll() + XCTAssertEqual(store.loadedCommands.first?.workspace?.color, "#111111") + + WorkspaceTabColorSettings.persistPaletteMap(["Codex Test": "#222222"]) + store.loadAll() + XCTAssertEqual(store.loadedCommands.first?.workspace?.color, "#222222") + } +} diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index a4b8a1097d42..736e24e89e03 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -1044,22 +1044,6 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertEqual(ws?.color, "#FF5733") } - func testDecodeWorkspaceCommandAcceptsNamedColor() throws { - let json = """ - { - "commands": [{ - "name": "Dev env", - "workspace": { - "name": "Development", - "color": "Indigo" - } - }] - } - """ - let config = try decode(json) - XCTAssertEqual(config.commands[0].workspace?.color, "#283593") - } - func testDecodeRestartBehaviors() throws { for behavior in ["recreate", "ignore", "confirm"] { let json = """ From 8969041d8243c6db90b362b5603dfb39d32ce517 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 28 Apr 2026 20:50:04 -0700 Subject: [PATCH 4/4] Make named color negative test hermetic --- cmuxTests/CmuxConfigNamedColorTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmuxTests/CmuxConfigNamedColorTests.swift b/cmuxTests/CmuxConfigNamedColorTests.swift index 2da29fbd8701..0b78c3ab471c 100644 --- a/cmuxTests/CmuxConfigNamedColorTests.swift +++ b/cmuxTests/CmuxConfigNamedColorTests.swift @@ -38,6 +38,10 @@ final class CmuxConfigNamedColorTests: XCTestCase { } func testDecodeWorkspaceCommandRejectsUnknownNamedColor() { + let suiteName = "cmux-config-unknown-color-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let json = """ { "commands": [{ @@ -49,7 +53,7 @@ final class CmuxConfigNamedColorTests: XCTestCase { }] } """ - XCTAssertThrowsError(try decode(json)) + XCTAssertThrowsError(try decode(json, colorDefaults: defaults)) } @MainActor