From 6584a01aef418d6cad187a5e0d45096586941518 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Fri, 13 Mar 2026 03:26:51 -0700 Subject: [PATCH] Add cmux themes command --- CLI/cmux.swift | 561 ++++++++++++++++++++++++++++++ Sources/AppDelegate.swift | 21 ++ Sources/GhosttyConfig.swift | 49 ++- Sources/GhosttyTerminalView.swift | 90 ++--- Sources/TerminalController.swift | 22 ++ 5 files changed, 697 insertions(+), 46 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8346d1ab..75f64401 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -934,6 +934,14 @@ struct CMUXCLI { return } + if command == "themes" { + try runThemes( + commandArgs: commandArgs, + jsonOutput: jsonOutput + ) + return + } + if command == "claude-teams" { try runClaudeTeams( commandArgs: commandArgs, @@ -4264,6 +4272,31 @@ struct CMUXCLI { Double check with the end user before sending anything. Review the message and attachments for secrets, private code, credentials, tokens, and other sensitive information first. """ + case "themes": + return """ + Usage: cmux themes + cmux themes list + cmux themes set + cmux themes set --light [--dark ] + cmux themes set --dark [--light ] + cmux themes clear + + List bundled and discovered Ghostty themes, show the current cmux light/dark + theme defaults, and update the default theme override used by cmux. + + Commands: + list List available themes and mark the current light/dark defaults + set Set the same theme for both light and dark appearance + set --light Set the light appearance theme + set --dark Set the dark appearance theme + clear Remove the cmux theme override and fall back to other config + + Examples: + cmux themes + cmux themes set "Catppuccin Mocha" + cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" + cmux themes clear + """ case "claude-teams": return String(localized: "cli.claude-teams.usage", defaultValue: """ Usage: cmux claude-teams [claude-args...] @@ -5330,6 +5363,533 @@ struct CMUXCLI { return true } + private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app" + private static let cmuxThemesBlockStart = "# cmux themes start" + private static let cmuxThemesBlockEnd = "# cmux themes end" + private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config" + + private struct ThemeSelection { + let rawValue: String? + let light: String? + let dark: String? + let sourcePath: String? + } + + private struct ThemeReloadStatus { + let requested: Bool + let targetBundleIdentifier: String + } + + private func runThemes(commandArgs: [String], jsonOutput: Bool) throws { + if commandArgs.isEmpty { + try printThemesList(jsonOutput: jsonOutput) + return + } + + guard let subcommand = commandArgs.first else { + try printThemesList(jsonOutput: jsonOutput) + return + } + + switch subcommand { + case "list": + if commandArgs.count > 1 { + throw CLIError(message: "themes list does not take any positional arguments") + } + try printThemesList(jsonOutput: jsonOutput) + case "set": + try runThemesSet( + args: Array(commandArgs.dropFirst()), + jsonOutput: jsonOutput + ) + case "clear": + if commandArgs.count > 1 { + throw CLIError(message: "themes clear does not take any positional arguments") + } + try runThemesClear(jsonOutput: jsonOutput) + default: + if subcommand.hasPrefix("-") { + throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.") + } + + try runThemesSet( + args: commandArgs, + jsonOutput: jsonOutput + ) + } + } + + private func printThemesList(jsonOutput: Bool) throws { + let themes = availableThemeNames() + let current = currentThemeSelection() + let configPath = try cmuxThemeOverrideConfigURL().path + + if jsonOutput { + let currentPayload: [String: Any] = [ + "raw_value": current.rawValue ?? NSNull(), + "light": current.light ?? NSNull(), + "dark": current.dark ?? NSNull(), + "source_path": current.sourcePath ?? NSNull() + ] + let payload: [String: Any] = [ + "themes": themes.map { theme in + [ + "name": theme, + "current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame, + "current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame + ] + }, + "current": currentPayload, + "config_path": configPath + ] + print(jsonString(payload)) + return + } + + print("Current light: \(current.light ?? "inherit")") + print("Current dark: \(current.dark ?? "inherit")") + print("Config: \(configPath)") + if let sourcePath = current.sourcePath { + print("Source: \(sourcePath)") + } + print("") + + guard !themes.isEmpty else { + print("No themes found.") + return + } + + for theme in themes { + var badges: [String] = [] + if current.light?.caseInsensitiveCompare(theme) == .orderedSame { + badges.append("light") + } + if current.dark?.caseInsensitiveCompare(theme) == .orderedSame { + badges.append("dark") + } + let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]" + print("\(theme)\(badgeText)") + } + } + + private func runThemesSet(args: [String], jsonOutput: Bool) throws { + let (lightOpt, rem0) = parseOption(args, name: "--light") + let (darkOpt, rem1) = parseOption(rem0, name: "--dark") + + if let unknown = rem1.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light , --dark ") + } + + let availableThemes = availableThemeNames() + let current = currentThemeSelection() + + let lightTheme: String? + let darkTheme: String? + + if lightOpt == nil && darkOpt == nil { + let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !joinedTheme.isEmpty else { + throw CLIError(message: "themes set requires a theme name or --light/--dark flags") + } + let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes) + lightTheme = resolved + darkTheme = resolved + } else { + if !rem1.isEmpty { + throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'") + } + lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light + darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark + } + + guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else { + throw CLIError(message: "themes set requires at least one theme") + } + + let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) + let reloadStatus = reloadThemesIfPossible() + + if jsonOutput { + let payload: [String: Any] = [ + "ok": true, + "light": lightTheme ?? NSNull(), + "dark": darkTheme ?? NSNull(), + "raw_value": rawThemeValue, + "config_path": configURL.path, + "reload_requested": reloadStatus.requested, + "reload_target_bundle_id": reloadStatus.targetBundleIdentifier + ] + print(jsonString(payload)) + return + } + + print( + "OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested" + ) + } + + private func runThemesClear(jsonOutput: Bool) throws { + let configURL = try clearManagedThemeOverride() + let reloadStatus = reloadThemesIfPossible() + + if jsonOutput { + let payload: [String: Any] = [ + "ok": true, + "cleared": true, + "config_path": configURL.path, + "reload_requested": reloadStatus.requested, + "reload_target_bundle_id": reloadStatus.targetBundleIdentifier + ] + print(jsonString(payload)) + return + } + + print("OK cleared config=\(configURL.path) reload=requested") + } + + private func currentThemeSelection() -> ThemeSelection { + var rawValue: String? + var sourcePath: String? + + for url in themeConfigSearchURLs() { + guard let contents = try? String(contentsOf: url, encoding: .utf8), + let nextValue = lastThemeDirective(in: contents) else { + continue + } + rawValue = nextValue + sourcePath = url.path + } + + return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath) + } + + private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { + return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath) + } + + var fallbackTheme: String? + var lightTheme: String? + var darkTheme: String? + + for token in rawValue.split(separator: ",").map(String.init) { + let entry = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !entry.isEmpty else { continue } + + let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count != 2 { + if fallbackTheme == nil { + fallbackTheme = entry + } + continue + } + + let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch key { + case "light": + if lightTheme == nil { + lightTheme = value + } + case "dark": + if darkTheme == nil { + darkTheme = value + } + default: + if fallbackTheme == nil { + fallbackTheme = value + } + } + } + + let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme + let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme + return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath) + } + + private func encodedThemeValue(light: String?, dark: String?) -> String? { + let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines) + + switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) { + case let (lightTheme?, darkTheme?): + return "light:\(lightTheme),dark:\(darkTheme)" + case let (lightTheme?, nil): + return "light:\(lightTheme)" + case let (nil, darkTheme?): + return "dark:\(darkTheme)" + case (nil, nil): + return nil + } + } + + private func availableThemeNames() -> [String] { + let fileManager = FileManager.default + var seen: Set = [] + var themes: [String] = [] + + for directoryURL in themeDirectoryURLs() { + guard let entries = try? fileManager.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for entry in entries { + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + guard values?.isDirectory != true else { continue } + guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue } + let name = entry.lastPathComponent + let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + if seen.insert(folded).inserted { + themes.append(name) + } + } + } + + return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + private func themeDirectoryURLs() -> [URL] { + let fileManager = FileManager.default + let processEnv = ProcessInfo.processInfo.environment + var urls: [URL] = [] + var seen: Set = [] + + func appendIfExisting(_ url: URL?) { + guard let url else { return } + let standardized = url.standardizedFileURL + guard fileManager.fileExists(atPath: standardized.path) else { return } + if seen.insert(standardized.path).inserted { + urls.append(standardized) + } + } + + if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !resourcesDir.isEmpty { + appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true)) + } + + appendIfExisting( + Bundle.main.resourceURL? + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + + if let executableURL = resolvedExecutableURL() { + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.lastPathComponent == "Resources" { + appendIfExisting( + current + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + } + if current.lastPathComponent == "Contents" { + appendIfExisting( + current + .appendingPathComponent("Resources", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + .appendingPathComponent("themes", isDirectory: true) + ) + } + + let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) + let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true) + if fileManager.fileExists(atPath: projectMarker.path), + fileManager.fileExists(atPath: repoThemes.path) { + appendIfExisting(repoThemes) + break + } + + guard let parent = parentSearchURL(for: current) else { break } + current = parent + } + } + + if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] { + for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) { + appendIfExisting( + URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true) + .appendingPathComponent("ghostty/themes", isDirectory: true) + ) + } + } + + appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true)) + appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true)) + appendIfExisting( + URL( + fileURLWithPath: NSString( + string: "~/Library/Application Support/com.mitchellh.ghostty/themes" + ).expandingTildeInPath, + isDirectory: true + ) + ) + + return urls + } + + private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CLIError(message: "Theme name cannot be empty") + } + if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { + return matched + } + if availableThemes.isEmpty { + return trimmed + } + throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.") + } + + private func themeConfigSearchURLs() -> [URL] { + let rawPaths = [ + "~/.config/ghostty/config", + "~/.config/ghostty/config.ghostty", + "~/Library/Application Support/com.mitchellh.ghostty/config", + "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", + "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config", + "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty", + ] + + return rawPaths.map { + URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false) + } + } + + private func lastThemeDirective(in contents: String) -> String? { + var lastValue: String? + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } + + let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue } + + let value = parts[1] + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + if !value.isEmpty { + lastValue = value + } + } + + return lastValue + } + + private func cmuxThemeOverrideConfigURL() throws -> URL { + guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + throw CLIError(message: "Unable to resolve Application Support directory") + } + return appSupport + .appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true) + .appendingPathComponent("config.ghostty", isDirectory: false) + } + + private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL { + let fileManager = FileManager.default + let configURL = try cmuxThemeOverrideConfigURL() + let directoryURL = configURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) + + let existingContents = (try? String(contentsOf: configURL, encoding: .utf8)) ?? "" + let strippedContents = removingManagedThemeOverride(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + let block = """ + \(Self.cmuxThemesBlockStart) + theme = \(rawThemeValue) + \(Self.cmuxThemesBlockEnd) + """ + + let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" + try nextContents.write(to: configURL, atomically: true, encoding: .utf8) + return configURL + } + + private func clearManagedThemeOverride() throws -> URL { + let fileManager = FileManager.default + let configURL = try cmuxThemeOverrideConfigURL() + guard let existingContents = try? String(contentsOf: configURL, encoding: .utf8) else { + return configURL + } + + let strippedContents = removingManagedThemeOverride(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if strippedContents.isEmpty { + try? fileManager.removeItem(at: configURL) + } else { + try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8) + } + + return configURL + } + + private func removingManagedThemeOverride(from contents: String) -> String { + let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"# + guard let range = contents.range(of: pattern, options: [.regularExpression]) else { + return contents + } + return contents.replacingCharacters(in: range, with: "") + } + + private func reloadThemesIfPossible() -> ThemeReloadStatus { + let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier + DistributedNotificationCenter.default().post( + name: Notification.Name(Self.cmuxThemesReloadNotificationName), + object: bundleIdentifier, + userInfo: nil + ) + return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) + } + + private func currentCmuxAppBundleIdentifier() -> String? { + if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + guard let executableURL = resolvedExecutableURL() else { + return nil + } + + var current = executableURL.deletingLastPathComponent().standardizedFileURL + while true { + if current.pathExtension == "app", + let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + if current.lastPathComponent == "Contents" { + let appURL = current.deletingLastPathComponent().standardizedFileURL + if appURL.pathExtension == "app", + let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty { + return bundleIdentifier + } + } + + guard let parent = parentSearchURL(for: current) else { + break + } + current = parent + } + + return nil + } + /// Escape and quote a string for safe embedding in a v1 socket command. /// The socket tokenizer treats `\` and `"` as special inside quoted strings, /// so both must be escaped before wrapping in double quotes. Newlines and @@ -8398,6 +8958,7 @@ struct CMUXCLI { welcome shortcuts feedback [--email --body [--image ...]] + themes [list|set|clear] claude-teams [claude-args...] ping capabilities diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e441d37c..9c4a55ba 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,10 @@ import Combine import ObjectiveC.runtime import Darwin +private enum CmuxThemeNotifications { + static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") +} + #if DEBUG enum CmuxTypingTiming { static let isEnabled: Bool = { @@ -2118,6 +2122,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleThemesReloadNotification(_:)), + name: CmuxThemeNotifications.reloadConfig, + object: Bundle.main.bundleIdentifier, + suspensionBehavior: .deliverImmediately + ) + #if DEBUG // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make // key-equivalent routing flaky. Force defaults for deterministic tests. @@ -10766,6 +10778,14 @@ private extension NSApplication { } } +private extension AppDelegate { + @objc func handleThemesReloadNotification(_ notification: Notification) { + DispatchQueue.main.async { + GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") + } + } +} + private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if cmuxIsWindowFirstResponderBypassActive() { @@ -11357,4 +11377,5 @@ private extension NSWindow { } return hitWebView === webView } + } diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index aff57538..370572ae 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -7,6 +7,7 @@ struct GhosttyConfig { case dark } + private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app" private static let loadCacheLock = NSLock() private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:] @@ -87,6 +88,52 @@ struct GhosttyConfig { loadCacheLock.unlock() } + private static func cmuxConfigPaths( + fileManager: FileManager = .default, + currentBundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> [String] { + guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return [] + } + + func paths(for bundleIdentifier: String) -> [String] { + let directory = appSupport.appendingPathComponent(bundleIdentifier, isDirectory: true) + return [ + directory.appendingPathComponent("config", isDirectory: false).path, + directory.appendingPathComponent("config.ghostty", isDirectory: false).path, + ] + } + + func hasConfig(_ paths: [String]) -> Bool { + paths.contains { path in + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType, + type == .typeRegular, + let size = attributes[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + } + + let releasePaths = paths(for: cmuxReleaseBundleIdentifier) + guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { + return releasePaths + } + if currentBundleIdentifier == cmuxReleaseBundleIdentifier { + return releasePaths + } + + let currentPaths = paths(for: currentBundleIdentifier) + if hasConfig(currentPaths) { + return currentPaths + } + if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { + return releasePaths + } + return [] + } + private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig { var config = GhosttyConfig() @@ -96,7 +143,7 @@ struct GhosttyConfig { "~/.config/ghostty/config.ghostty", "~/Library/Application Support/com.mitchellh.ghostty/config", "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", - ].map { NSString(string: $0).expandingTildeInPath } + ].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths() for path in configPaths { if let contents = readConfigFile(at: path) { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..8eca2e02 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1026,9 +1026,9 @@ class GhosttyApp { private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) - loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) + loadCmuxAppSupportGhosttyConfigIfNeeded(config) loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } @@ -1229,20 +1229,44 @@ class GhosttyApp { return true } - static func shouldLoadReleaseAppSupportGhosttyConfig( + static func cmuxAppSupportConfigURLs( currentBundleIdentifier: String?, - currentConfigFileSize: Int?, - currentLegacyConfigFileSize: Int?, - releaseConfigFileSize: Int?, - releaseLegacyConfigFileSize: Int? - ) -> Bool { - guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false } + appSupportDirectory: URL, + fileManager: FileManager = .default + ) -> [URL] { + guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] } - let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0 - guard !hasCurrentAppSupportConfig else { return false } + func configURLs(for bundleIdentifier: String) -> [URL] { + let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true) + return [ + directory.appendingPathComponent("config", isDirectory: false), + directory.appendingPathComponent("config.ghostty", isDirectory: false) + ] + } - let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0 - return hasReleaseAppSupportConfig + func hasConfig(_ urls: [URL]) -> Bool { + urls.contains { url in + guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), + let type = attrs[.type] as? FileAttributeType, + type == .typeRegular, + let size = attrs[.size] as? NSNumber else { + return false + } + return size.intValue > 0 + } + } + + let currentURLs = configURLs(for: currentBundleIdentifier) + if hasConfig(currentURLs) { + return currentURLs + } + if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) { + let releaseURLs = configURLs(for: releaseBundleIdentifier) + if hasConfig(releaseURLs) { + return releaseURLs + } + } + return [] } static func shouldApplyDefaultBackgroundUpdate( @@ -1282,52 +1306,28 @@ class GhosttyApp { return true } - private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { + private func loadCmuxAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } guard let currentBundleIdentifier = Bundle.main.bundleIdentifier, !currentBundleIdentifier.isEmpty else { return } - - let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true) - let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true) - let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) - let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false) - let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) - let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false) - - func fileSize(_ url: URL) -> Int? { - guard let attrs = try? fm.attributesOfItem(atPath: url.path), - let size = attrs[.size] as? NSNumber else { return nil } - return size.intValue - } - - let releaseConfigSize = fileSize(releaseConfig) - let releaseLegacyConfigSize = fileSize(releaseLegacyConfig) - - guard Self.shouldLoadReleaseAppSupportGhosttyConfig( + let urls = Self.cmuxAppSupportConfigURLs( currentBundleIdentifier: currentBundleIdentifier, - currentConfigFileSize: fileSize(currentConfig), - currentLegacyConfigFileSize: fileSize(currentLegacyConfig), - releaseConfigFileSize: releaseConfigSize, - releaseLegacyConfigFileSize: releaseLegacyConfigSize - ) else { return } + appSupportDirectory: appSupport, + fileManager: fm + ) + guard !urls.isEmpty else { return } - if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 { - releaseLegacyConfig.path.withCString { path in - ghostty_config_load_file(config, path) - } - } - - if let releaseConfigSize, releaseConfigSize > 0 { - releaseConfig.path.withCString { path in + for url in urls { + url.path.withCString { path in ghostty_config_load_file(config, path) } } #if DEBUG Self.initLog( - "loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)" + "loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))" ) #endif #endif diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bf4862c6..9a430bc0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1619,6 +1619,9 @@ class TerminalController { case "close_surface": return closeSurface(args) + case "reload_config": + return reloadConfig(args) + case "refresh_surfaces": return refreshSurfaces() @@ -9661,6 +9664,7 @@ class TerminalController { focus_pane - Focus a pane focus_surface_by_panel - Focus surface by panel ID close_surface [id|idx] - Close surface (collapse split) + reload_config [soft] - Reload Ghostty config and refresh terminals refresh_surfaces - Force refresh all terminals surface_health [workspace] - Check view health of all surfaces @@ -13820,6 +13824,24 @@ class TerminalController { return result } + private func reloadConfig(_ args: String) -> String { + let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let soft: Bool + switch trimmed { + case "", "full": + soft = false + case "soft": + soft = true + default: + return "ERROR: Usage: reload_config [soft]" + } + + v2MainSync { + GhosttyApp.shared.reloadConfiguration(soft: soft, source: "socket.reload_config") + } + return soft ? "OK Reloaded config (soft)" : "OK Reloaded config" + } + private func refreshSurfaces() -> String { guard let tabManager = tabManager else { return "ERROR: TabManager not available" }