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
31 changes: 22 additions & 9 deletions CLI/CMUXCLI+ThemeSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ extension CMUXCLI {

appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true))
appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true))
for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories(environment: processEnv) {
appendIfExisting(
appSupportDirectory
.appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true)
.appendingPathComponent("themes", isDirectory: true)
)
}
appendIfExisting(
URL(
fileURLWithPath: NSString(
Expand Down Expand Up @@ -131,34 +138,40 @@ extension CMUXCLI {
configURL("~/.config/ghostty/config"),
configURL("~/.config/ghostty/config.ghostty"),
]
var seen = Set(urls.map { $0.standardizedFileURL.path })

if let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
func append(_ url: URL) {
let standardized = url.standardizedFileURL
if seen.insert(standardized.path).inserted {
urls.append(standardized)
}
}

for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories(
environment: ProcessInfo.processInfo.environment
) {
let ghosttyDirectory = appSupportDirectory.appendingPathComponent(
"com.mitchellh.ghostty",
isDirectory: true
)
let legacyGhosttyConfigURL = ghosttyDirectory.appendingPathComponent("config", isDirectory: false)
let currentGhosttyConfigURL = ghosttyDirectory.appendingPathComponent("config.ghostty", isDirectory: false)

urls.append(currentGhosttyConfigURL)
append(currentGhosttyConfigURL)
if shouldLoadLegacyGhosttyConfig(
newConfigURL: currentGhosttyConfigURL,
legacyConfigURL: legacyGhosttyConfigURL,
fileManager: fileManager
) {
urls.append(legacyGhosttyConfigURL)
append(legacyGhosttyConfigURL)
}

let cmuxDirectory = appSupportDirectory.appendingPathComponent(
Self.cmuxThemeOverrideBundleIdentifier,
isDirectory: true
)
urls.append(cmuxDirectory.appendingPathComponent("config", isDirectory: false))
urls.append(cmuxDirectory.appendingPathComponent("config.ghostty", isDirectory: false))
} else {
urls.append(configURL("~/Library/Application Support/com.mitchellh.ghostty/config.ghostty"))
urls.append(configURL("~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config"))
urls.append(configURL("~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty"))
append(cmuxDirectory.appendingPathComponent("config", isDirectory: false))
append(cmuxDirectory.appendingPathComponent("config.ghostty", isDirectory: false))
}

return urls
Expand Down
38 changes: 38 additions & 0 deletions Sources/CmuxApplicationSupportDirectories.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

enum CmuxApplicationSupportDirectories {
static func userDirectories(
environment: [String: String],
fileManager: FileManager = .default
) -> [URL] {
var urls: [URL] = []
var seen: Set<String> = []

func append(_ url: URL?) {
guard let url else { return }
let standardized = url.standardizedFileURL
if seen.insert(standardized.path).inserted {
urls.append(standardized)
}
}

append(fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first)

if let fixedHome = environment["CFFIXED_USER_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!fixedHome.isEmpty {
append(
URL(fileURLWithPath: fixedHome, isDirectory: true)
.appendingPathComponent("Library/Application Support", isDirectory: true)
)
}

append(
URL(
fileURLWithPath: NSString(string: "~/Library/Application Support").expandingTildeInPath,
isDirectory: true
)
)

return urls
}
}
9 changes: 9 additions & 0 deletions Sources/GhosttyConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,15 @@ struct GhosttyConfig {
// 4) Common system/user fallback locations.
appendUniquePath("/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(themeName)")
appendUniquePath("~/.config/ghostty/themes/\(themeName)")
for appSupportDirectory in CmuxApplicationSupportDirectories.userDirectories(environment: environment) {
appendUniquePath(
appSupportDirectory
.appendingPathComponent(CmuxGhosttyConfigPathResolver.releaseBundleIdentifier, isDirectory: true)
.appendingPathComponent("themes", isDirectory: true)
.appendingPathComponent(themeName, isDirectory: false)
.path
)
}
appendUniquePath("~/Library/Application Support/com.mitchellh.ghostty/themes/\(themeName)")

return paths
Expand Down
6 changes: 6 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; };
A5001450A5001450A5001450 /* FilePreviewNativeBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001451A5001451A5001451 /* FilePreviewNativeBackground.swift */; };
D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; };
D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; };
D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; };
D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; };
D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; };
D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; };
Expand Down Expand Up @@ -843,6 +845,7 @@
A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; };
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; };
D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxApplicationSupportDirectories.swift; sourceTree = "<group>"; };
A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = "<group>"; };
C0DEF0C10000000000000002 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = "<group>"; };
C0DEF0C20000000000000002 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1375,6 +1378,7 @@
C0DEF0C10000000000000002 /* AgentForkSupport.swift */,
C0DEF0C20000000000000002 /* SemanticVersion.swift */,
A5001641 /* RemoteRelayZshBootstrap.swift */,
D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */,
A5001651 /* CmuxConfig.swift */,
C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */,
C0DEF0A10000000000000002 /* CmuxConfigUI.swift */,
Expand Down Expand Up @@ -1937,6 +1941,7 @@
A5F10011A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore.swift in Sources */,
A5F10013A1B2C3D4E5F60719 /* KeyboardShortcutSettingsFileStore+Template.swift in Sources */,
C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */,
D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */,
A50012F5 /* KeyboardLayout.swift in Sources */,
A5001003 /* TabManager.swift in Sources */,
C3677003000000000000001 /* TabManager+CompatibilityTypes.swift in Sources */,
Expand Down Expand Up @@ -2162,6 +2167,7 @@
B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */,
B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */,
C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */,
D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */,
B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
145 changes: 145 additions & 0 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,76 @@ final class GhosttyConfigTests: XCTestCase {
XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light"))
}

func testThemeSearchPathsIncludeCmuxUserThemesDirectory() {
let paths = GhosttyConfig.themeSearchPaths(
forThemeName: "Zag Light",
environment: [:],
bundleResourceURL: nil
)

XCTAssertTrue(
paths.contains(
"\(NSHomeDirectory())/Library/Application Support/com.cmuxterm.app/themes/Zag Light"
)
)
}

func testThemeSearchPathsIncludeCmuxUserThemesDirectoryFromFixedHome() {
let fixedHome = "/tmp/cmux-fixed-home-\(UUID().uuidString)"
let paths = GhosttyConfig.themeSearchPaths(
forThemeName: "Zag Light",
environment: ["CFFIXED_USER_HOME": fixedHome],
bundleResourceURL: nil
)

XCTAssertTrue(
paths.contains(
"\(fixedHome)/Library/Application Support/com.cmuxterm.app/themes/Zag Light"
)
)
}

func testThemesListIncludesCmuxUserThemesDirectory() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-user-theme-list-\(UUID().uuidString)")
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }

let themesDirectory = root
.appendingPathComponent("Library/Application Support/com.cmuxterm.app/themes", isDirectory: true)
try fileManager.createDirectory(at: themesDirectory, withIntermediateDirectories: true)
try "background = #ffffff\nforeground = #1f2328\n".write(
to: themesDirectory.appendingPathComponent("Zag Light", isDirectory: false),
atomically: true,
encoding: .utf8
)
let configURL = themesDirectory
.deletingLastPathComponent()
.appendingPathComponent("config.ghostty", isDirectory: false)
try "theme = Zag Light\n".write(to: configURL, atomically: true, encoding: .utf8)

let result = runCLI(
try bundledCLIPath(),
arguments: ["--json", "themes", "list"],
environment: ["CFFIXED_USER_HOME": root.path],
timeout: 10
)

XCTAssertFalse(result.timedOut, result.output)
XCTAssertEqual(result.status, 0, result.output)

let payload = try XCTUnwrap(
JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any]
)
let themes = try XCTUnwrap(payload["themes"] as? [[String: Any]])
XCTAssertTrue(themes.contains { ($0["name"] as? String) == "Zag Light" }, result.output)
let current = try XCTUnwrap(payload["current"] as? [String: Any])
XCTAssertEqual(current["light"] as? String, "Zag Light")
XCTAssertEqual(current["dark"] as? String, "Zag Light")
XCTAssertEqual(current["source_path"] as? String, configURL.path)
}

func testCmuxDefaultThemeConfigContentsSkipsInvalidUTF8Candidate() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
Expand Down Expand Up @@ -597,6 +667,81 @@ final class GhosttyConfigTests: XCTestCase {
)
}

private struct CLIResult {
let status: Int32
let output: String
let timedOut: Bool
}

private func bundledCLIPath() throws -> String {
let fileManager = FileManager.default
let appBundleURL = Bundle(for: Self.self)
.bundleURL
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let enumerator = fileManager.enumerator(
at: appBundleURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)

while let item = enumerator?.nextObject() as? URL {
guard item.lastPathComponent == "cmux",
item.path.contains(".app/Contents/Resources/bin/cmux") else {
continue
}
return item.path
}

throw XCTSkip("Bundled cmux CLI not found in \(appBundleURL.path)")
}

private func runCLI(
_ cliPath: String,
arguments: [String],
environment overrides: [String: String],
timeout: TimeInterval
) -> CLIResult {
let process = Process()
let outputPipe = Pipe()
process.executableURL = URL(fileURLWithPath: cliPath)
process.arguments = arguments
var environment = ProcessInfo.processInfo.environment
for (key, value) in overrides {
environment[key] = value
}
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
process.environment = environment
process.standardInput = FileHandle.nullDevice
process.standardOutput = outputPipe
process.standardError = outputPipe

do {
try process.run()
} catch {
return CLIResult(status: -1, output: String(describing: error), timedOut: false)
}

let exitSignal = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
exitSignal.signal()
}

let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut
if timedOut {
process.terminate()
_ = exitSignal.wait(timeout: .now() + 1)
}

let output = String(
data: outputPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
) ?? ""
return CLIResult(status: process.terminationStatus, output: output, timedOut: timedOut)
}

}

final class WorkspaceChromeThemeTests: XCTestCase {
Expand Down
Loading