diff --git a/CHANGELOG.md b/CHANGELOG.md index 496f1f3c..3a1828ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Idle CPU use is lower: moving the mouse and checking git status for workspaces you're not looking at no longer do unnecessary background work. ### Added +- Terminal themes are now selectable in Settings with separate light and dark choices. The UI, `programa themes`, and `app.terminalTheme` in `settings.json` share one managed override and apply changes to open terminals without relaunching. - A local diagnostics log at `~/Library/Logs/Programa/diagnostics.log` now records connection problems (like CLI socket errors) so issues can be diagnosed after the fact. It's a plain file on your machine; nothing in it is ever sent anywhere. ## [0.4.0] - 2026-08-03 diff --git a/CLI/CLI+Themes.swift b/CLI/CLI+Themes.swift index e6cdb908..41c70a5d 100644 --- a/CLI/CLI+Themes.swift +++ b/CLI/CLI+Themes.swift @@ -9,18 +9,6 @@ import Security #endif extension ProgramaCLI { - private static let programaThemeOverrideBundleIdentifier = "com.darkroom.programa" - private static let programaThemesBlockStart = "# programa themes start" - private static let programaThemesBlockEnd = "# programa themes end" - private static let programaThemesReloadNotificationName = "com.darkroom.programa.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 @@ -42,10 +30,12 @@ extension ProgramaCLI { throw CLIError(message: "Bundled Ghostty theme picker helper not found") } - let selection = currentThemeSelection() + let themeStore = TerminalThemeStore.live() + let selection = themeStore.currentSelection() var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_THEME_PICKER_CONFIG"] = try programaThemeOverrideConfigURL().path - environment["PROGRAMA_THEME_PICKER_BUNDLE_ID"] = currentProgramaAppBundleIdentifier() ?? Self.programaThemeOverrideBundleIdentifier + environment["PROGRAMA_THEME_PICKER_CONFIG"] = themeStore.managedConfigURL.path + environment["PROGRAMA_THEME_PICKER_BUNDLE_ID"] = currentProgramaAppBundleIdentifier() + ?? TerminalThemeStore.overrideBundleIdentifier environment["PROGRAMA_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue environment["PROGRAMA_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" if let light = selection.light { @@ -65,7 +55,7 @@ extension ProgramaCLI { ) } - private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { + private func defaultThemePickerTargetMode(current: TerminalThemeSelection) -> ThemePickerTargetMode { if let light = current.light, let dark = current.dark, light.caseInsensitiveCompare(dark) == .orderedSame { @@ -221,8 +211,9 @@ extension ProgramaCLI { private func printThemesList(jsonOutput: Bool) throws { let themes = availableThemeNames() - let current = currentThemeSelection() - let configPath = try programaThemeOverrideConfigURL().path + let themeStore = TerminalThemeStore.live() + let current = themeStore.currentSelection() + let configPath = themeStore.managedConfigURL.path if jsonOutput { let currentPayload: [String: Any] = [ @@ -302,11 +293,11 @@ extension ProgramaCLI { darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark } - guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else { + guard let rawThemeValue = TerminalThemeStore.encodedThemeValue(light: lightTheme, dark: darkTheme) else { throw CLIError(message: "themes set requires at least one theme") } - let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) + let configURL = try TerminalThemeStore.live().set(rawThemeValue: rawThemeValue).configURL let reloadStatus = reloadThemesIfPossible() if jsonOutput { @@ -329,7 +320,7 @@ extension ProgramaCLI { } private func runThemesClear(jsonOutput: Bool) throws { - let configURL = try clearManagedThemeOverride() + let configURL = try TerminalThemeStore.live().clear().configURL let reloadStatus = reloadThemesIfPossible() if jsonOutput { @@ -347,82 +338,8 @@ extension ProgramaCLI { 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 currentThemeSelection() -> TerminalThemeSelection { + TerminalThemeStore.live().currentSelection() } private func availableThemeNames() -> [String] { @@ -549,138 +466,10 @@ extension ProgramaCLI { throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'programa 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.programaThemeOverrideBundleIdentifier)/config", - "~/Library/Application Support/\(Self.programaThemeOverrideBundleIdentifier)/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 programaThemeOverrideConfigURL() 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.programaThemeOverrideBundleIdentifier, isDirectory: true) - .appendingPathComponent("config.ghostty", isDirectory: false) - } - - private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL { - let fileManager = FileManager.default - let configURL = try programaThemeOverrideConfigURL() - let directoryURL = configURL.deletingLastPathComponent() - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - - let existingContents = try readOptionalThemeOverrideContents(at: configURL) ?? "" - let strippedContents = removingManagedThemeOverride(from: existingContents) - .trimmingCharacters(in: .whitespacesAndNewlines) - let block = """ - \(Self.programaThemesBlockStart) - theme = \(rawThemeValue) - \(Self.programaThemesBlockEnd) - """ - - 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 programaThemeOverrideConfigURL() - guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else { - return configURL - } - - let strippedContents = removingManagedThemeOverride(from: existingContents) - .trimmingCharacters(in: .whitespacesAndNewlines) - - if strippedContents.isEmpty { - do { - try fileManager.removeItem(at: configURL) - } catch { - guard !isThemeOverrideFileNotFoundError(error) else { - return configURL - } - throw error - } - } else { - try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8) - } - - return configURL - } - - private func readOptionalThemeOverrideContents(at url: URL) throws -> String? { - do { - return try String(contentsOf: url, encoding: .utf8) - } catch { - guard isThemeOverrideFileNotFoundError(error) else { - throw error - } - return nil - } - } - - private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain { - return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError - } - if nsError.domain == NSPOSIXErrorDomain { - return nsError.code == ENOENT - } - return false - } - - private func removingManagedThemeOverride(from contents: String) -> String { - let pattern = #"(?ms)\n?# programa themes start\n.*?\n# programa themes end\n?"# - guard let regex = try? NSRegularExpression(pattern: pattern) else { - return contents - } - let fullRange = NSRange(contents.startIndex.. ThemeReloadStatus { - let bundleIdentifier = currentProgramaAppBundleIdentifier() ?? Self.programaThemeOverrideBundleIdentifier - DistributedNotificationCenter.default().post( - name: Notification.Name(Self.programaThemesReloadNotificationName), - object: nil, - userInfo: ["bundleIdentifier": bundleIdentifier] - ) + let bundleIdentifier = currentProgramaAppBundleIdentifier() + ?? TerminalThemeStore.overrideBundleIdentifier + TerminalThemeStore.requestReload(targetBundleIdentifier: bundleIdentifier) return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d4e8ae9e..541088c0 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ NRPA00005 /* SettingsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00006 /* SettingsModels.swift */; }; NRPA00007 /* SettingsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00008 /* SettingsComponents.swift */; }; NRPA00009 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00010 /* SettingsView.swift */; }; + THTM0002 /* TerminalThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = THTM0001 /* TerminalThemeStore.swift */; }; + THTM0003 /* TerminalThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = THTM0001 /* TerminalThemeStore.swift */; }; REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */ = {isa = PBXBuildFile; fileRef = REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */; }; A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; @@ -433,6 +435,7 @@ NRPA00006 /* SettingsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModels.swift; sourceTree = ""; }; NRPA00008 /* SettingsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComponents.swift; sourceTree = ""; }; NRPA00010 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + THTM0001 /* TerminalThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalThemeStore.swift; sourceTree = ""; }; REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealization.swift; sourceTree = ""; }; A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -902,7 +905,8 @@ NRPA00004 /* DebugWindows.swift */, NRPA00006 /* SettingsModels.swift */, NRPA00008 /* SettingsComponents.swift */, - NRPA00010 /* SettingsView.swift */, + NRPA00010 /* SettingsView.swift */, + THTM0001 /* TerminalThemeStore.swift */, REND0001A1B2C3D4E5F60719 /* RendererRealization.swift */, A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, @@ -1462,6 +1466,7 @@ NRPA00005 /* SettingsModels.swift in Sources */, NRPA00007 /* SettingsComponents.swift in Sources */, NRPA00009 /* SettingsView.swift in Sources */, + THTM0002 /* TerminalThemeStore.swift in Sources */, REND0002A1B2C3D4E5F60719 /* RendererRealization.swift in Sources */, A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, @@ -1780,7 +1785,8 @@ RCAP000001 /* CLI+Recap.swift in Sources */, B9000033A1B2C3D4E5F60719 /* CLI+SSH.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */, - B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */, + B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */, + THTM0003 /* TerminalThemeStore.swift in Sources */, B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */, B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */, B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */, diff --git a/README.md b/README.md index 7c390944..a7ae7b54 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ Programa is a terminal, a browser, notifications, workspaces, and a CLI to contr ⌘⇧P opens the command palette, which lists every action. Full reference: [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md). Everything is editable in `Settings → Keyboard Shortcuts`. +## Terminal themes + +Choose separate light and dark Ghostty themes in `Settings → Appearance → Terminal`, with matching CLI and `settings.json` support. See [docs/terminal-themes.md](docs/terminal-themes.md). + ## Agent skill Agents running inside programa (Claude Code, Codex, OpenCode) can drive the app itself, splitting panes, reading a sibling pane's output, spawning and coordinating a helper agent, all without stealing your focus. `programa claude/codex/opencode install-integration` installs [`SKILL.md`](SKILL.md) alongside the existing hooks; see [docs/agent-skill.md](docs/agent-skill.md) for the full walkthrough. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a0013ea1..f78c6cd7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -10793,6 +10793,23 @@ } } }, + "settings.section.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, "settings.shortcuts.chords": { "extractionState": "manual", "localizations": { @@ -10970,6 +10987,125 @@ } } }, + "settings.terminalTheme.changeFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t change the terminal theme." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルテーマを変更できませんでした。" + } + } + } + }, + "settings.terminalTheme.dark": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark Theme" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダークテーマ" + } + } + } + }, + "settings.terminalTheme.dark.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used when Programa has a dark appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programa がダーク表示のときに使用します。" + } + } + } + }, + "settings.terminalTheme.light": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Light Theme" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ライトテーマ" + } + } + } + }, + "settings.terminalTheme.light.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used when Programa has a light appearance." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programa がライト表示のときに使用します。" + } + } + } + }, + "settings.terminalTheme.managedByFile": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed in settings.json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "settings.json で管理されています" + } + } + } + }, + "settings.terminalTheme.useGhosttyConfiguration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Ghostty Configuration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ghostty の設定を使用" + } + } + } + }, "settings.tab.appearance": { "extractionState": "manual", "localizations": { diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index 776edf88..3f6403b7 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -30,6 +30,35 @@ "default": "system", "description": "App appearance mode." }, + "terminalTheme": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "light": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ], + "default": null, + "description": "Ghostty theme used with Programa's light appearance." + }, + "dark": { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "null" } + ], + "default": null, + "description": "Ghostty theme used with Programa's dark appearance." + } + } + }, + { "type": "null" } + ], + "default": null, + "description": "Terminal themes from Settings > Appearance. Use null, or null for both variants, to remove Programa's managed theme override and inherit Ghostty configuration." + }, "newWorkspacePlacement": { "type": "string", "enum": ["top", "afterCurrent", "end"], diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index d150435f..27655005 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -556,6 +556,58 @@ struct GhosttyConfig { return paths } + /// Enumerates the theme directories derived from the same path resolver used to load a + /// selected theme. This keeps Settings discovery aligned with actual Ghostty resolution. + static func availableThemeNames( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleResourceURL: URL? = Bundle.main.resourceURL, + fileManager: FileManager = .default + ) -> [String] { + let probeName = ".programa-theme-catalog-probe" + let directories = themeSearchPaths( + forThemeName: probeName, + environment: environment, + bundleResourceURL: bundleResourceURL + ).map { + URL(fileURLWithPath: $0, isDirectory: false).deletingLastPathComponent() + } + + var seenDirectories: Set = [] + var seenThemeNames: Set = [] + var themeNames: [String] = [] + + for directory in directories { + let standardizedDirectory = directory.standardizedFileURL + guard seenDirectories.insert(standardizedDirectory.path).inserted, + let entries = try? fileManager.contentsOfDirectory( + at: standardizedDirectory, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for entry in entries { + let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + guard values?.isDirectory != true, + values?.isRegularFile == true || values?.isRegularFile == nil else { + continue + } + + let name = entry.lastPathComponent + let foldedName = name.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + if seenThemeNames.insert(foldedName).inserted { + themeNames.append(name) + } + } + } + + return themeNames.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + private static func readConfigFile(at path: String) -> String? { let fileManager = FileManager.default guard fileManager.fileExists(atPath: path) else { return nil } diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 3942d412..3c68e6de 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -27,6 +27,7 @@ final class ProgramaSettingsFileStore { private static let backupsDefaultsKey = "programa.settingsFile.backups.v1" fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" fileprivate static let socketPasswordBackupIdentifier = "automation.socketPassword" + fileprivate static let terminalThemeBackupIdentifier = "app.terminalTheme" static var defaultPrimaryPath: String { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -57,6 +58,8 @@ final class ProgramaSettingsFileStore { private let fallbackPath: String? private let fileManager: FileManager private let notificationCenter: NotificationCenter + private let terminalThemeStore: TerminalThemeStore + private let terminalThemeReloadHandler: () -> Void private let stateLock = NSLock() private var primaryWatcher: ShortcutSettingsFileWatcher? @@ -76,12 +79,21 @@ final class ProgramaSettingsFileStore { fallbackPath: String? = ProgramaSettingsFileStore.defaultFallbackPath, fileManager: FileManager = .default, notificationCenter: NotificationCenter = .default, + terminalThemeStore: TerminalThemeStore = .live(), + terminalThemeReloadHandler: @escaping () -> Void = { + TerminalThemeStore.requestReload( + targetBundleIdentifier: Bundle.main.bundleIdentifier + ?? TerminalThemeStore.overrideBundleIdentifier + ) + }, startWatching: Bool = true ) { self.primaryPath = primaryPath self.fallbackPath = fallbackPath self.fileManager = fileManager self.notificationCenter = notificationCenter + self.terminalThemeStore = terminalThemeStore + self.terminalThemeReloadHandler = terminalThemeReloadHandler bootstrapPrimaryTemplateIfNeeded() reload() @@ -157,6 +169,10 @@ final class ProgramaSettingsFileStore { synchronized { shortcutsByAction[action] != nil } } + func isTerminalThemeManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.terminalTheme != nil } + } + func settingsFileURLForEditing() -> URL { if let activeSourcePath = synchronized({ activeSourcePath }) { return URL(fileURLWithPath: activeSourcePath) @@ -351,6 +367,49 @@ final class ProgramaSettingsFileStore { if let value = jsonBool(section["commandPaletteSearchesAllSurfaces"]) { snapshot.managedUserDefaults[CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey] = .bool(value) } + if let rawTerminalTheme = section["terminalTheme"] { + if rawTerminalTheme is NSNull { + snapshot.managedCustomSettings.terminalTheme = ManagedTerminalTheme(light: nil, dark: nil) + } else if let terminalTheme = rawTerminalTheme as? [String: Any] { + let light = parseTerminalThemeName( + terminalTheme["light"], + path: "app.terminalTheme.light", + sourcePath: sourcePath + ) + let dark = parseTerminalThemeName( + terminalTheme["dark"], + path: "app.terminalTheme.dark", + sourcePath: sourcePath + ) + if light.isValid && dark.isValid { + snapshot.managedCustomSettings.terminalTheme = ManagedTerminalTheme( + light: light.value, + dark: dark.value + ) + } + } else { + logInvalid("app.terminalTheme", sourcePath: sourcePath) + } + } + } + + private func parseTerminalThemeName( + _ rawValue: Any?, + path: String, + sourcePath: String + ) -> (isValid: Bool, value: String?) { + guard let rawValue else { return (true, nil) } + if rawValue is NSNull { return (true, nil) } + guard let string = rawValue as? String else { + logInvalid(path, sourcePath: sourcePath) + return (false, nil) + } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + logInvalid(path, sourcePath: sourcePath) + return (false, nil) + } + return (true, trimmed) } private func parseNotificationsSection( @@ -898,6 +957,10 @@ final class ProgramaSettingsFileStore { backups[Self.socketPasswordBackupIdentifier] == nil { backups[Self.socketPasswordBackupIdentifier] = currentSocketPasswordBackupValue() } + if snapshot.managedCustomSettings.terminalTheme != nil, + backups[Self.terminalThemeBackupIdentifier] == nil { + backups[Self.terminalThemeBackupIdentifier] = currentTerminalThemeBackupValue() + } } for identifier in currentManagedIdentifiers.subtracting(nextManagedIdentifiers) { @@ -936,6 +999,10 @@ final class ProgramaSettingsFileStore { } } } + + if let terminalTheme = settings.terminalTheme { + applyTerminalTheme(light: terminalTheme.light, dark: terminalTheme.dark) + } } private func restoreBackup(_ backup: BackupValue, for identifier: String) { @@ -955,6 +1022,26 @@ final class ProgramaSettingsFileStore { default: break } + case Self.terminalThemeBackupIdentifier: + do { + let mutation: TerminalThemeMutation + switch backup { + case .string(let rawValue): + mutation = try terminalThemeStore.set(rawThemeValue: rawValue) + case .absent: + mutation = try terminalThemeStore.clear() + default: + return + } + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to restore terminal theme: %@", + String(describing: error) + ) + } default: restoreUserDefaultsBackup(backup, for: identifier) } @@ -999,6 +1086,27 @@ final class ProgramaSettingsFileStore { return .string(current) } + private func currentTerminalThemeBackupValue() -> BackupValue { + guard let rawValue = terminalThemeStore.managedRawThemeValue() else { + return .absent + } + return .string(rawValue) + } + + private func applyTerminalTheme(light: String?, dark: String?) { + do { + let mutation = try terminalThemeStore.set(light: light, dark: dark) + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to apply terminal theme: %@", + String(describing: error) + ) + } + } + private func applyManagedUserDefaultsValue(_ value: ManagedSettingsValue, for defaultsKey: String) { let defaults = UserDefaults.standard if defaultsKey == WorkspaceTabColorSettings.paletteKey, @@ -1199,6 +1307,10 @@ final class ProgramaSettingsFileStore { [ "app": [ "appearance": AppearanceSettings.defaultMode.rawValue, + "terminalTheme": [ + "light": NSNull(), + "dark": NSNull(), + ], "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, "minimalMode": WorkspacePresentationModeSettings.defaultMode == .minimal, "preferredEditor": "", @@ -1326,12 +1438,18 @@ private enum ManagedStringOverride: Equatable { case clear } +private struct ManagedTerminalTheme: Equatable { + let light: String? + let dark: String? +} + private struct ManagedCustomSettings: Equatable { var trustedDirectories: [String]? var socketPassword: ManagedStringOverride? + var terminalTheme: ManagedTerminalTheme? var isEmpty: Bool { - trustedDirectories == nil && socketPassword == nil + trustedDirectories == nil && socketPassword == nil && terminalTheme == nil } var managedIdentifiers: Set { @@ -1342,6 +1460,9 @@ private struct ManagedCustomSettings: Equatable { if socketPassword != nil { identifiers.insert(ProgramaSettingsFileStore.socketPasswordBackupIdentifier) } + if terminalTheme != nil { + identifiers.insert(ProgramaSettingsFileStore.terminalThemeBackupIdentifier) + } return identifiers } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index bbb41b81..b54ab526 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -94,6 +94,7 @@ struct SettingsView: View { @ObservedObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + @StateObject private var terminalThemeSettings = TerminalThemeSettingsModel() @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -150,6 +151,20 @@ struct SettingsView: View { ) } + private var terminalLightThemeSelection: Binding { + Binding( + get: { terminalThemeSettings.lightTheme }, + set: { terminalThemeSettings.selectLightTheme($0) } + ) + } + + private var terminalDarkThemeSelection: Binding { + Binding( + get: { terminalThemeSettings.darkTheme }, + set: { terminalThemeSettings.selectDarkTheme($0) } + ) + } + private var selectedSocketControlMode: SocketControlMode { SocketControlSettings.migrateMode(socketControlMode) } @@ -919,6 +934,60 @@ struct SettingsView: View { } } + SettingsSectionHeader( + title: String(localized: "settings.section.terminal", defaultValue: "Terminal") + ) + SettingsCard { + SettingsPickerRow( + String(localized: "settings.terminalTheme.light", defaultValue: "Light Theme"), + subtitle: terminalThemeSettings.isManagedBySettingsFile + ? String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json") + : String(localized: "settings.terminalTheme.light.subtitle", defaultValue: "Used when Programa has a light appearance."), + controlWidth: pickerColumnWidth, + selection: terminalLightThemeSelection, + accessibilityId: "TerminalLightThemePicker" + ) { + Text( + String( + localized: "settings.terminalTheme.useGhosttyConfiguration", + defaultValue: "Use Ghostty Configuration" + ) + ).tag("") + ForEach(terminalThemeSettings.themeNames, id: \.self) { themeName in + Text(themeName).tag(themeName) + } + } + .disabled(terminalThemeSettings.isManagedBySettingsFile) + + SettingsCardDivider() + + SettingsPickerRow( + String(localized: "settings.terminalTheme.dark", defaultValue: "Dark Theme"), + subtitle: terminalThemeSettings.isManagedBySettingsFile + ? String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json") + : String(localized: "settings.terminalTheme.dark.subtitle", defaultValue: "Used when Programa has a dark appearance."), + controlWidth: pickerColumnWidth, + selection: terminalDarkThemeSelection, + accessibilityId: "TerminalDarkThemePicker" + ) { + Text( + String( + localized: "settings.terminalTheme.useGhosttyConfiguration", + defaultValue: "Use Ghostty Configuration" + ) + ).tag("") + ForEach(terminalThemeSettings.themeNames, id: \.self) { themeName in + Text(themeName).tag(themeName) + } + } + .disabled(terminalThemeSettings.isManagedBySettingsFile) + + if let errorMessage = terminalThemeSettings.errorMessage { + SettingsCardDivider() + SettingsCardNote(errorMessage) + } + } + } @ViewBuilder @@ -1765,6 +1834,7 @@ struct SettingsView: View { sidebarTintOpacity = SidebarTintDefaults.opacity sidebarMatchTerminalBackground = false showClaudeQuota = true + terminalThemeSettings.clearManagedOverride() showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" @@ -1785,6 +1855,118 @@ struct SettingsView: View { } } +@MainActor +private final class TerminalThemeSettingsModel: ObservableObject { + @Published private(set) var themeNames: [String] = [] + @Published private(set) var lightTheme = "" + @Published private(set) var darkTheme = "" + @Published private(set) var isManagedBySettingsFile = false + @Published private(set) var errorMessage: String? + + private let store: TerminalThemeStore + private let notificationCenter: NotificationCenter + private var configReloadObserver: NSObjectProtocol? + + init( + store: TerminalThemeStore = .live(), + notificationCenter: NotificationCenter = .default + ) { + self.store = store + self.notificationCenter = notificationCenter + refresh() + configReloadObserver = notificationCenter.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.refresh() + } + } + } + + deinit { + if let configReloadObserver { + notificationCenter.removeObserver(configReloadObserver) + } + } + + func selectLightTheme(_ themeName: String) { + guard !isManagedBySettingsFile else { + refresh() + return + } + if themeName.isEmpty { + clearManagedOverride() + return + } + let current = store.currentSelection() + apply(light: themeName, dark: current.dark) + } + + func selectDarkTheme(_ themeName: String) { + guard !isManagedBySettingsFile else { + refresh() + return + } + if themeName.isEmpty { + clearManagedOverride() + return + } + let current = store.currentSelection() + apply(light: current.light, dark: themeName) + } + + func clearManagedOverride() { + guard !isManagedBySettingsFile else { return } + apply(light: nil, dark: nil) + } + + private func apply(light: String?, dark: String?) { + do { + let mutation = try store.set(light: light, dark: dark) + errorMessage = nil + refresh() + if mutation.didChange { + TerminalThemeStore.requestReload( + targetBundleIdentifier: Bundle.main.bundleIdentifier + ?? TerminalThemeStore.overrideBundleIdentifier + ) + } + } catch { + let prefix = String( + localized: "settings.terminalTheme.changeFailed", + defaultValue: "Couldn’t change the terminal theme." + ) + errorMessage = "\(prefix) \(error.localizedDescription)" + refreshSelectionAndOwnership() + } + } + + private func refresh() { + let current = store.currentSelection() + var names = GhosttyConfig.availableThemeNames() + for selectedName in [current.light, current.dark].compactMap({ $0 }) { + if !names.contains(where: { $0.caseInsensitiveCompare(selectedName) == .orderedSame }) { + names.append(selectedName) + } + } + themeNames = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + refreshSelectionAndOwnership(current: current) + } + + private func refreshSelectionAndOwnership( + current: TerminalThemeSelection? = nil + ) { + let current = current ?? store.currentSelection() + let hasManagedOverride = store.managedRawThemeValue() != nil + lightTheme = hasManagedOverride ? current.light ?? "" : "" + darkTheme = hasManagedOverride ? current.dark ?? "" : "" + isManagedBySettingsFile = KeyboardShortcutSettings.settingsFileStore + .isTerminalThemeManagedByFile() + } +} + private struct SettingsTopOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 diff --git a/Sources/TerminalThemeStore.swift b/Sources/TerminalThemeStore.swift new file mode 100644 index 00000000..98c53bc5 --- /dev/null +++ b/Sources/TerminalThemeStore.swift @@ -0,0 +1,290 @@ +import Foundation +import Darwin + +struct TerminalThemeSelection: Equatable { + let rawValue: String? + let light: String? + let dark: String? + let sourcePath: String? +} + +struct TerminalThemeMutation: Equatable { + let configURL: URL + let didChange: Bool +} + +/// Owns Programa's managed `theme` block so the app and CLI cannot drift. +/// +/// User Ghostty configuration remains untouched. Programa only replaces the block delimited by +/// `managedBlockStart`/`managedBlockEnd` in its Application Support config and preserves every +/// unrelated directive in that file. +struct TerminalThemeStore { + static let overrideBundleIdentifier = "com.darkroom.programa" + static let managedBlockStart = "# programa themes start" + static let managedBlockEnd = "# programa themes end" + static let reloadNotificationName = "com.darkroom.programa.themes.reload-config" + + private static let managedBlockPattern = #"(?ms)\n?# programa themes start\r?\n(.*?)\r?\n# programa themes end\n?"# + + let fileManager: FileManager + let managedConfigURL: URL + let configSearchURLs: [URL] + + init( + fileManager: FileManager = .default, + managedConfigURL: URL, + configSearchURLs: [URL] + ) { + self.fileManager = fileManager + self.managedConfigURL = managedConfigURL + self.configSearchURLs = configSearchURLs + } + + static func live(fileManager: FileManager = .default) -> TerminalThemeStore { + let home = fileManager.homeDirectoryForCurrentUser + let applicationSupport = home + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + let programaDirectory = applicationSupport + .appendingPathComponent(overrideBundleIdentifier, isDirectory: true) + let managedConfigURL = programaDirectory + .appendingPathComponent("config.ghostty", isDirectory: false) + let ghosttyDirectory = home + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("ghostty", isDirectory: true) + let ghosttyApplicationSupport = applicationSupport + .appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + + return TerminalThemeStore( + fileManager: fileManager, + managedConfigURL: managedConfigURL, + configSearchURLs: [ + ghosttyDirectory.appendingPathComponent("config", isDirectory: false), + ghosttyDirectory.appendingPathComponent("config.ghostty", isDirectory: false), + ghosttyApplicationSupport.appendingPathComponent("config", isDirectory: false), + ghosttyApplicationSupport.appendingPathComponent("config.ghostty", isDirectory: false), + programaDirectory.appendingPathComponent("config", isDirectory: false), + managedConfigURL, + ] + ) + } + + func currentSelection() -> TerminalThemeSelection { + var rawValue: String? + var sourcePath: String? + + for url in configSearchURLs { + guard let contents = try? String(contentsOf: url, encoding: .utf8), + let nextValue = Self.lastThemeDirective(in: contents) else { + continue + } + rawValue = nextValue + sourcePath = url.path + } + + return Self.parseSelection(rawValue: rawValue, sourcePath: sourcePath) + } + + func managedRawThemeValue() -> String? { + guard let contents = try? String(contentsOf: managedConfigURL, encoding: .utf8), + let regex = try? NSRegularExpression(pattern: Self.managedBlockPattern), + let match = regex.matches( + in: contents, + range: NSRange(contents.startIndex.. 1, + let bodyRange = Range(match.range(at: 1), in: contents) else { + return nil + } + return Self.lastThemeDirective(in: String(contents[bodyRange])) + } + + func set(light: String?, dark: String?) throws -> TerminalThemeMutation { + guard let rawThemeValue = Self.encodedThemeValue(light: light, dark: dark) else { + return try clear() + } + return try set(rawThemeValue: rawThemeValue) + } + + func set(rawThemeValue: String) throws -> TerminalThemeMutation { + let trimmedThemeValue = rawThemeValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedThemeValue.isEmpty else { return try clear() } + + let existingContents = try readOptionalContents(at: managedConfigURL) ?? "" + let strippedContents = Self.removingManagedBlock(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + let block = """ + \(Self.managedBlockStart) + theme = \(trimmedThemeValue) + \(Self.managedBlockEnd) + """ + let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" + + guard nextContents != existingContents else { + return TerminalThemeMutation(configURL: managedConfigURL, didChange: false) + } + + try fileManager.createDirectory( + at: managedConfigURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try nextContents.write(to: managedConfigURL, atomically: true, encoding: .utf8) + return TerminalThemeMutation(configURL: managedConfigURL, didChange: true) + } + + func clear() throws -> TerminalThemeMutation { + guard let existingContents = try readOptionalContents(at: managedConfigURL) else { + return TerminalThemeMutation(configURL: managedConfigURL, didChange: false) + } + + let contentsWithoutManagedBlock = Self.removingManagedBlock(from: existingContents) + guard contentsWithoutManagedBlock != existingContents else { + return TerminalThemeMutation(configURL: managedConfigURL, didChange: false) + } + + let strippedContents = contentsWithoutManagedBlock.trimmingCharacters(in: .whitespacesAndNewlines) + if strippedContents.isEmpty { + do { + try fileManager.removeItem(at: managedConfigURL) + } catch { + guard Self.isFileNotFoundError(error) else { throw error } + } + } else { + try strippedContents.appending("\n").write( + to: managedConfigURL, + atomically: true, + encoding: .utf8 + ) + } + + return TerminalThemeMutation(configURL: managedConfigURL, didChange: true) + } + + static func parseSelection(rawValue: String?, sourcePath: String?) -> TerminalThemeSelection { + guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty else { + return TerminalThemeSelection(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 } + } + } + + return TerminalThemeSelection( + rawValue: rawValue, + light: lightTheme ?? fallbackTheme ?? darkTheme, + dark: darkTheme ?? fallbackTheme ?? lightTheme, + sourcePath: sourcePath + ) + } + + static func encodedThemeValue(light: String?, dark: String?) -> String? { + let normalizedLight = normalizedThemeName(light) + let normalizedDark = normalizedThemeName(dark) + + switch (normalizedLight, normalizedDark) { + 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 + } + } + + static func requestReload(targetBundleIdentifier: String) { + DistributedNotificationCenter.default().post( + name: Notification.Name(reloadNotificationName), + object: nil, + userInfo: ["bundleIdentifier": targetBundleIdentifier] + ) + } + + private static func normalizedThemeName(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static 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, + 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 static func removingManagedBlock(from contents: String) -> String { + guard let regex = try? NSRegularExpression(pattern: managedBlockPattern) else { + return contents + } + return regex.stringByReplacingMatches( + in: contents, + range: NSRange(contents.startIndex.. String? { + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + guard Self.isFileNotFoundError(error) else { throw error } + return nil + } + } + + private static func isFileNotFoundError(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError + } + if nsError.domain == NSPOSIXErrorDomain { + return nsError.code == ENOENT + } + return false + } +} diff --git a/docs/terminal-themes.md b/docs/terminal-themes.md new file mode 100644 index 00000000..7d8b49a1 --- /dev/null +++ b/docs/terminal-themes.md @@ -0,0 +1,32 @@ +# Terminal themes + +Programa reads the themes installed with its embedded Ghostty, Ghostty's standard user theme directories, and any directories configured through `GHOSTTY_RESOURCES_DIR` or `XDG_DATA_DIRS`. + +Choose separate light and dark themes in **Settings → Appearance → Terminal**. Changes apply to every open terminal without relaunching Programa. Choosing **Use Ghostty Configuration** removes Programa's managed override and returns theme selection to your Ghostty config. + +The same selection is available from the CLI: + +```bash +programa themes list +programa themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" +programa themes clear +``` + +Both surfaces write the managed block in `~/Library/Application Support/com.darkroom.programa/config.ghostty`, preserving unrelated directives in that file. + +## settings.json + +Set `app.terminalTheme` in `~/.config/programa/settings.json` to manage the selection as configuration: + +```jsonc +{ + "app": { + "terminalTheme": { + "light": "Catppuccin Latte", + "dark": "Catppuccin Mocha" + } + } +} +``` + +While this key is present, the Settings pickers are read-only. Removing the key restores the theme selection that was active before `settings.json` took ownership. Set the value to `null` (or set both variants to `null`) to explicitly inherit your Ghostty configuration while keeping the setting file-managed. diff --git a/programaTests/GhosttyConfigTests.swift b/programaTests/GhosttyConfigTests.swift index 99123820..b3b39dc9 100644 --- a/programaTests/GhosttyConfigTests.swift +++ b/programaTests/GhosttyConfigTests.swift @@ -129,6 +129,37 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light")) } + func testAvailableThemeNamesEnumeratesDirectoriesResolvedByThemeSearchPaths() throws { + let resourcesRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("programa-theme-catalog-\(UUID().uuidString)", isDirectory: true) + let themesDirectory = resourcesRoot.appendingPathComponent("themes", isDirectory: true) + try FileManager.default.createDirectory(at: themesDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: resourcesRoot) } + + try "background = #ffffff\n".write( + to: themesDirectory.appendingPathComponent("Cloud Light", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "background = #111111\n".write( + to: themesDirectory.appendingPathComponent("Midnight Dark", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try FileManager.default.createDirectory( + at: themesDirectory.appendingPathComponent("Not A Theme", isDirectory: true), + withIntermediateDirectories: true + ) + + let names = GhosttyConfig.availableThemeNames( + environment: ["GHOSTTY_RESOURCES_DIR": resourcesRoot.path], + bundleResourceURL: nil, + fileManager: .default + ) + + XCTAssertEqual(names, ["Cloud Light", "Midnight Dark"]) + } + func testLoadThemeResolvesPairedThemeValueByColorScheme() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)") diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index ef25d383..8551f645 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -1201,6 +1201,120 @@ final class KeyboardShortcutSettingsFileStoreTests: XCTestCase { } +final class TerminalThemeSettingsTests: XCTestCase { + private let settingsFileBackupsDefaultsKey = "programa.settingsFile.backups.v1" + + func testManagedOverrideRoundTripsAndPreservesUnrelatedConfig() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + try "font-size = 15\n".write(to: configURL, atomically: true, encoding: .utf8) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + + let firstMutation = try store.set(light: "Cloud Light", dark: "Midnight Dark") + XCTAssertTrue(firstMutation.didChange) + XCTAssertEqual(firstMutation.configURL, configURL) + + let selection = store.currentSelection() + XCTAssertEqual(selection.rawValue, "light:Cloud Light,dark:Midnight Dark") + XCTAssertEqual(selection.light, "Cloud Light") + XCTAssertEqual(selection.dark, "Midnight Dark") + XCTAssertEqual(selection.sourcePath, configURL.path) + + let managedContents = try String(contentsOf: configURL, encoding: .utf8) + XCTAssertTrue(managedContents.contains("font-size = 15")) + XCTAssertTrue(managedContents.contains("# programa themes start")) + XCTAssertTrue(managedContents.contains("theme = light:Cloud Light,dark:Midnight Dark")) + + let repeatedMutation = try store.set(light: "Cloud Light", dark: "Midnight Dark") + XCTAssertFalse(repeatedMutation.didChange) + + let clearMutation = try store.clear() + XCTAssertTrue(clearMutation.didChange) + XCTAssertEqual(try String(contentsOf: configURL, encoding: .utf8), "font-size = 15\n") + XCTAssertNil(store.managedRawThemeValue()) + } + + func testSettingsFileManagesThemeAndRestoresPriorOverrideWhenRemoved() throws { + let defaults = UserDefaults.standard + let previousBackups = defaults.data(forKey: settingsFileBackupsDefaultsKey) + defer { + if let previousBackups { + defaults.set(previousBackups, forKey: settingsFileBackupsDefaultsKey) + } else { + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + } + } + defaults.removeObject(forKey: settingsFileBackupsDefaultsKey) + + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsURL = directoryURL.appendingPathComponent("settings.json", isDirectory: false) + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let themeStore = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + _ = try themeStore.set(light: "Original Light", dark: "Original Dark") + try writeSettingsFile( + """ + { + "app": { + "terminalTheme": { + "light": "Managed Light", + "dark": "Managed Dark" + } + } + } + """, + to: settingsURL + ) + + var reloadRequestCount = 0 + let settingsStore = ProgramaSettingsFileStore( + primaryPath: settingsURL.path, + fallbackPath: nil, + fileManager: .default, + notificationCenter: .default, + terminalThemeStore: themeStore, + terminalThemeReloadHandler: { reloadRequestCount += 1 }, + startWatching: false + ) + + XCTAssertTrue(settingsStore.isTerminalThemeManagedByFile()) + XCTAssertEqual(themeStore.currentSelection().light, "Managed Light") + XCTAssertEqual(themeStore.currentSelection().dark, "Managed Dark") + XCTAssertEqual(reloadRequestCount, 1) + + try writeSettingsFile("{ \"app\": {} }", to: settingsURL) + settingsStore.reload() + + XCTAssertFalse(settingsStore.isTerminalThemeManagedByFile()) + XCTAssertEqual(themeStore.currentSelection().light, "Original Light") + XCTAssertEqual(themeStore.currentSelection().dark, "Original Dark") + XCTAssertEqual(reloadRequestCount, 2) + } + + private func makeTemporaryDirectory() throws -> URL { + let directoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL + } + + private func writeSettingsFile(_ contents: String, to url: URL) throws { + try contents.write(to: url, atomically: true, encoding: .utf8) + } +} + + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forDigit: 9, workspaceCount: 1), 0)