From 32b7d1bbc40a1ed7a172beb07865c549ec7bfa3c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sat, 26 Sep 2026 12:27:29 -0400 Subject: [PATCH 1/3] Reload Ghostty config on file change and surface config errors Watch every file the Ghostty config reads (top-level configs, config-file includes, user theme files) and reload through the existing Reload Configuration path after a 300 ms trailing debounce. Evaluations compare file contents off the main thread, so directory noise and cmux's own writes (cmux themes set, Settings) do not trigger a second reload, while an edit pending during a cmux-started reload is never absorbed. Watchers re-arm when the include/theme set changes; FileWatcher already follows atomic and move-aside saves. After each full config load, Ghostty's diagnostics feed a policy that shows a small non-activating notice once per distinct error set (first three errors, Open Config jumps to the line), hides it when the errors are fixed, and drops cmux's own inline-fragment diagnostics. Refs #1047, #1304, #570. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../GhosttyConfigDiagnostic.swift | 63 ++++ .../GhosttyConfigDiagnosticsNotice.swift | 28 ++ ...osttyConfigDiagnosticsNoticeDecision.swift | 9 + ...GhosttyConfigDiagnosticsNoticePolicy.swift | 58 ++++ .../GhosttyConfigDiscovery.swift | 2 +- ...hosttyConfigLiveReloadSnapshotReader.swift | 71 +++++ ...FileWatcherGhosttyConfigChangeSource.swift | 40 +++ .../GhosttyConfigChangeSource.swift | 14 + .../GhosttyConfigChangeSubscription.swift | 27 ++ .../GhosttyConfigDiscovery+LiveReload.swift | 195 ++++++++++++ .../GhosttyConfigLiveReloadCoordinator.swift | 213 +++++++++++++ .../GhosttyConfigLiveReloadOutcome.swift | 13 + .../GhosttyConfigLiveReloadSnapshot.swift | 45 +++ ...osttyConfigLiveReloadSnapshotReading.swift | 10 + ...tyConfigDiagnosticsNoticePolicyTests.swift | 113 +++++++ ...atcherGhosttyConfigChangeSourceTests.swift | 88 ++++++ ...sttyConfigLiveReloadCoordinatorTests.swift | 290 +++++++++++++++++ ...GhosttyConfigLiveReloadSnapshotTests.swift | 129 ++++++++ Resources/Localizable.xcstrings | 295 ++++++++++++++++++ Sources/AppDelegate.swift | 43 +++ Sources/GhosttyApp+ConfigDiagnostics.swift | 15 + ...sttyConfigDiagnosticsNoticePresenter.swift | 175 +++++++++++ .../GhosttyConfigDiagnosticsNoticeView.swift | 86 +++++ Sources/GhosttyTerminalView.swift | 5 + cmux.xcodeproj/project.pbxproj | 12 + docs/configuration.md | 22 ++ 26 files changed, 2060 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnostic.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnosticsNotice.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticeDecision.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticePolicy.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/DiscoveryGhosttyConfigLiveReloadSnapshotReader.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/FileWatcherGhosttyConfigChangeSource.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSource.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSubscription.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigDiscovery+LiveReload.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadCoordinator.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadOutcome.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshot.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotReading.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticePolicyTests.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/FileWatcherGhosttyConfigChangeSourceTests.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadCoordinatorTests.swift create mode 100644 Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotTests.swift create mode 100644 Sources/GhosttyApp+ConfigDiagnostics.swift create mode 100644 Sources/GhosttyConfigDiagnosticsNoticePresenter.swift create mode 100644 Sources/GhosttyConfigDiagnosticsNoticeView.swift diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnostic.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnostic.swift new file mode 100644 index 000000000000..601b5d2a7032 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiagnostics/GhosttyConfigDiagnostic.swift @@ -0,0 +1,63 @@ +import Foundation + +/// One diagnostic Ghostty reported while loading its configuration. +/// +/// Ghostty formats file diagnostics as `::: `; +/// ``filePath`` and ``line`` are parsed from that prefix so the notice can +/// open the offending file. +public struct GhosttyConfigDiagnostic: Equatable, Hashable, Sendable { + /// Synthetic path prefix cmux uses when it loads its own inline config + /// fragments (see `loadInlineGhosttyConfig`). Diagnostics from those are + /// cmux bugs, not user errors. + public static let cmuxInlineConfigPathPrefix = "/__cmux_inline__/" + + /// The full message exactly as Ghostty formatted it. + public let message: String + /// The config file the diagnostic points at, when it has a file location. + public let filePath: String? + /// The 1-based line in ``filePath``, when present. + public let line: Int? + + /// Parses a Ghostty diagnostic message. + /// + /// - Parameter message: The text from `ghostty_config_get_diagnostic`. + public init(message: String) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = trimmed + let location = Self.parseFileLocation(trimmed) + self.filePath = location.path + self.line = location.line + } + + /// Whether the diagnostic comes from a cmux-generated inline fragment + /// rather than a file the user can edit. + public var isFromCmuxInlineConfig: Bool { + filePath?.hasPrefix(Self.cmuxInlineConfigPathPrefix) == true + || message.hasPrefix(Self.cmuxInlineConfigPathPrefix) + } + + private static func parseFileLocation(_ message: String) -> (path: String?, line: Int?) { + guard message.hasPrefix("/") || message.hasPrefix("~") else { return (nil, nil) } + // Find the first "::" after the path. + var searchStart = message.startIndex + while let colon = message[searchStart...].firstIndex(of: ":") { + let digitsStart = message.index(after: colon) + let digits = message[digitsStart...].prefix(while: \.isASCIIDigit) + let afterDigits = message.index(digitsStart, offsetBy: digits.count) + if !digits.isEmpty, + afterDigits < message.endIndex, + message[afterDigits] == ":", + let line = Int(digits) { + return (String(message[..? + + /// Creates a policy that has presented nothing. + public init() {} + + /// Records one config load's raw diagnostic messages and returns what to + /// do with the notice. + /// + /// - Parameter messages: Messages in Ghostty's order, as returned by + /// `ghostty_config_get_diagnostic`. + /// - Returns: The notice decision for this load. + public mutating func decision(forMessages messages: [String]) -> GhosttyConfigDiagnosticsNoticeDecision { + var seen = Set() + var diagnostics: [GhosttyConfigDiagnostic] = [] + for message in messages { + let diagnostic = GhosttyConfigDiagnostic(message: message) + guard !diagnostic.message.isEmpty, + !diagnostic.isFromCmuxInlineConfig, + seen.insert(diagnostic).inserted else { continue } + diagnostics.append(diagnostic) + } + + guard !diagnostics.isEmpty else { + guard lastPresented != nil else { return .unchanged } + lastPresented = nil + return .dismiss + } + guard seen != lastPresented else { return .unchanged } + lastPresented = seen + return .present( + GhosttyConfigDiagnosticsNotice( + listedDiagnostics: Array(diagnostics.prefix(Self.maximumListedDiagnostics)), + totalCount: diagnostics.count + ) + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift index e04d96fc6b13..63eac887986b 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift @@ -20,7 +20,7 @@ public struct GhosttyConfigDiscovery { /// own identifier. public static let releaseBundleIdentifier = CmuxGhosttyConfigPathResolver.releaseBundleIdentifier - private let fileReader: any GhosttyConfigFileReading + let fileReader: any GhosttyConfigFileReading private let fontProbe: any GhosttyFontProbing private let pathResolver: CmuxGhosttyConfigPathResolver diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/DiscoveryGhosttyConfigLiveReloadSnapshotReader.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/DiscoveryGhosttyConfigLiveReloadSnapshotReader.swift new file mode 100644 index 000000000000..eba83a4ccfbe --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/DiscoveryGhosttyConfigLiveReloadSnapshotReader.swift @@ -0,0 +1,71 @@ +public import Foundation + +/// ``GhosttyConfigLiveReloadSnapshotReading`` that resolves cmux's Ghostty +/// config files through ``GhosttyConfigDiscovery`` and reads them from disk. +/// +/// Every input is a `Sendable` value captured at construction, and the +/// discovery value (with its non-`Sendable` file reader) is built inside each +/// call, so ``snapshot()`` runs entirely off the caller's actor. +/// +/// ```swift +/// let reader = DiscoveryGhosttyConfigLiveReloadSnapshotReader( +/// currentBundleIdentifier: Bundle.main.bundleIdentifier, +/// appSupportDirectory: FileManager.default.urls( +/// for: .applicationSupportDirectory, in: .userDomainMask +/// ).first, +/// configHomeDirectory: DiscoveryGhosttyConfigLiveReloadSnapshotReader +/// .configHomeDirectory(environment: ProcessInfo.processInfo.environment) +/// ) +/// ``` +public struct DiscoveryGhosttyConfigLiveReloadSnapshotReader: GhosttyConfigLiveReloadSnapshotReading { + private let currentBundleIdentifier: String? + private let appSupportDirectory: URL? + private let configHomeDirectory: String + private let makeDiscovery: @Sendable () -> GhosttyConfigDiscovery + + /// Creates a reader. + /// + /// - Parameters: + /// - currentBundleIdentifier: The running app's bundle identifier, which + /// selects the cmux Application Support config directory. + /// - appSupportDirectory: The user's Application Support directory. + /// - configHomeDirectory: The XDG config home that holds + /// `ghostty/themes`. + /// - makeDiscovery: Builds the discovery value for one read. Defaults to + /// a `FileManager`-backed discovery; tests inject an in-memory reader. + public init( + currentBundleIdentifier: String?, + appSupportDirectory: URL?, + configHomeDirectory: String, + makeDiscovery: @escaping @Sendable () -> GhosttyConfigDiscovery = { GhosttyConfigDiscovery() } + ) { + self.currentBundleIdentifier = currentBundleIdentifier + self.appSupportDirectory = appSupportDirectory + self.configHomeDirectory = configHomeDirectory + self.makeDiscovery = makeDiscovery + } + + /// The XDG config home Ghostty uses: `$XDG_CONFIG_HOME` when set to a + /// non-empty value, otherwise `~/.config`. + /// + /// - Parameter environment: The process environment. + /// - Returns: An absolute or `~`-prefixed directory path. + public static func configHomeDirectory(environment: [String: String]) -> String { + if let xdgConfigHome = environment["XDG_CONFIG_HOME"], + !xdgConfigHome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return xdgConfigHome + } + return "~/.config" + } + + public func snapshot() async -> GhosttyConfigLiveReloadSnapshot { + let discovery = makeDiscovery() + return discovery.liveReloadSnapshot( + topLevelPaths: discovery.liveReloadTopLevelPaths( + currentBundleIdentifier: currentBundleIdentifier, + appSupportDirectory: appSupportDirectory + ), + configHomeDirectory: configHomeDirectory + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/FileWatcherGhosttyConfigChangeSource.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/FileWatcherGhosttyConfigChangeSource.swift new file mode 100644 index 000000000000..407ae904965a --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/FileWatcherGhosttyConfigChangeSource.swift @@ -0,0 +1,40 @@ +import CmuxFoundation + +/// ``GhosttyConfigChangeSource`` backed by one ``FileWatcher`` per path. +/// +/// ``FileWatcher`` also watches the nearest existing ancestor directory and +/// reattaches to the current inode after every event, so a file that is +/// created later, saved atomically (temp file renamed over the original), or +/// moved aside and rewritten (Vim's default backup behavior) keeps reporting +/// changes. Events are not throttled here: the coordinator needs the first +/// event promptly to mark a change pending, and debounces the evaluation +/// itself. +public struct FileWatcherGhosttyConfigChangeSource: GhosttyConfigChangeSource { + /// Creates the source. + public init() {} + + public func subscribe(toPaths paths: [String]) async -> GhosttyConfigChangeSubscription { + let (events, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + // FileWatcher.init opens descriptors synchronously; this nonisolated + // async requirement runs off the caller's actor. + let watchers = paths.map { FileWatcher(path: $0) } + let forwarders = watchers.map { watcher in + Task { + for await _ in watcher.events { + continuation.yield(()) + } + } + } + return GhosttyConfigChangeSubscription(events: events) { + for forwarder in forwarders { + forwarder.cancel() + } + for watcher in watchers { + await watcher.stop() + } + continuation.finish() + } + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSource.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSource.swift new file mode 100644 index 000000000000..760d1bc52c76 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSource.swift @@ -0,0 +1,14 @@ +/// Produces change notifications for a set of Ghostty config paths. +/// +/// The production conformer is ``FileWatcherGhosttyConfigChangeSource``; tests +/// inject a source whose events they yield by hand. +public protocol GhosttyConfigChangeSource: Sendable { + /// Starts watching `paths` and returns the subscription. + /// + /// Paths need not exist yet. The subscription must keep reporting changes + /// after an editor replaces a file by renaming a new one over it. + /// + /// - Parameter paths: Absolute file paths to watch. + /// - Returns: The live subscription; cancel it before dropping it. + func subscribe(toPaths paths: [String]) async -> GhosttyConfigChangeSubscription +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSubscription.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSubscription.swift new file mode 100644 index 000000000000..48600f26a36f --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigChangeSubscription.swift @@ -0,0 +1,27 @@ +/// A live subscription to filesystem changes for a set of config paths, +/// returned by ``GhosttyConfigChangeSource/subscribe(toPaths:)``. +public struct GhosttyConfigChangeSubscription: Sendable { + /// Yields once per change notification. Changes are invalidations, not + /// quantities, so a slow consumer may see several changes as one element. + public let events: AsyncStream + + private let tearDown: @Sendable () async -> Void + + /// Creates a subscription. + /// + /// - Parameters: + /// - events: The change stream. + /// - tearDown: Stops the underlying watchers and finishes `events`. + public init( + events: AsyncStream, + tearDown: @escaping @Sendable () async -> Void + ) { + self.events = events + self.tearDown = tearDown + } + + /// Stops watching and finishes ``events``. Idempotent. + public func cancel() async { + await tearDown() + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigDiscovery+LiveReload.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigDiscovery+LiveReload.swift new file mode 100644 index 000000000000..7582c851b770 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigDiscovery+LiveReload.swift @@ -0,0 +1,195 @@ +public import Foundation +import CmuxFoundation + +extension GhosttyConfigDiscovery { + /// Returns every top-level Ghostty config file cmux could load, whether or + /// not it exists yet, for the config file watcher. + /// + /// This is a superset of + /// ``loadedGhosttyConfigScanPaths(currentBundleIdentifier:appSupportDirectory:)``: + /// that list drops a legacy `config` or an empty cmux config file, but + /// writing to one of those can change which file cmux loads, so the watcher + /// still observes it. + /// + /// - Parameters: + /// - currentBundleIdentifier: The running app's bundle identifier. + /// - appSupportDirectory: The user's Application Support directory. + /// - Returns: Absolute, de-duplicated paths. + public func liveReloadTopLevelPaths( + currentBundleIdentifier: String?, + appSupportDirectory: URL? + ) -> [String] { + var paths = loadedGhosttyConfigScanPaths( + currentBundleIdentifier: currentBundleIdentifier, + appSupportDirectory: appSupportDirectory + ) + if let appSupportDirectory { + let nativeDirectory = appSupportDirectory + .appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + var cmuxDirectories = [ + appSupportDirectory.appendingPathComponent( + Self.releaseBundleIdentifier, + isDirectory: true + ), + ] + if let currentBundleIdentifier, !currentBundleIdentifier.isEmpty { + cmuxDirectories.append( + CmuxGhosttyConfigPathResolver().configDirectoryURL( + currentBundleIdentifier: currentBundleIdentifier, + appSupportDirectory: appSupportDirectory + ) + ) + } + for directory in [nativeDirectory] + cmuxDirectories { + paths.append(directory.appendingPathComponent("config.ghostty", isDirectory: false).path) + paths.append(directory.appendingPathComponent("config", isDirectory: false).path) + } + } + return Self.uniqueStandardizedPaths(paths) + } + + /// Reads the config files reachable from `topLevelPaths` and returns what + /// the config file watcher needs: the paths to observe and their contents. + /// + /// Follows `config-file` includes (relative to the including file, with + /// the optional `?` prefix) and adds user theme files named by `theme` + /// directives: an absolute theme path, or `/ghostty/themes/` + /// for each side of a `light:…,dark:…` pair. Themes bundled with the app do + /// not change at runtime and are not watched. Symlinked files also watch + /// their resolved target, so dotfile setups that replace the target file + /// are noticed. + /// + /// Performs file I/O through the injected ``GhosttyConfigFileReading``; + /// call it off the main thread. + /// + /// - Parameters: + /// - topLevelPaths: The top-level config candidates, typically + /// ``liveReloadTopLevelPaths(currentBundleIdentifier:appSupportDirectory:)``. + /// - configHomeDirectory: The XDG config home (`$XDG_CONFIG_HOME`, or + /// `~/.config`) whose `ghostty/themes` directory holds user themes. + /// - resolvesSymlinks: Whether to add each file's resolved symlink + /// target. Tests that use in-memory readers pass `false`. + /// - Returns: The watch paths and file contents. + public func liveReloadSnapshot( + topLevelPaths: [String], + configHomeDirectory: String, + resolvesSymlinks: Bool = true + ) -> GhosttyConfigLiveReloadSnapshot { + var watchedPaths: [String] = [] + var watchedPathSet = Set() + var contentsByPath: [String: String] = [:] + var themeValues: [String] = [] + + func watch(_ rawPath: String) -> String? { + let path = Self.standardizedPath(rawPath) + guard !path.isEmpty else { return nil } + if watchedPathSet.insert(path).inserted { + watchedPaths.append(path) + } + if resolvesSymlinks { + let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + if resolved != path, watchedPathSet.insert(resolved).inserted { + watchedPaths.append(resolved) + } + } + return path + } + + func read(_ path: String, recursiveConfigPaths: inout [String]) { + guard contentsByPath[path] == nil, + let contents = fileReader.contents(atPath: path) else { return } + contentsByPath[path] = contents + let parentDirectory = (path as NSString).deletingLastPathComponent + for line in contents.components(separatedBy: .newlines) { + guard let entry = Self.parsedConfigEntry(from: line), + let value = entry.value else { continue } + switch entry.key { + case "config-file": + Self.applyConfigFileDirective( + value, + valueWasQuoted: entry.valueWasQuoted, + parentDir: parentDirectory, + recursiveConfigPaths: &recursiveConfigPaths + ) + case "theme": + themeValues.append(value) + default: + continue + } + } + } + + var recursiveConfigPaths: [String] = [] + for topLevelPath in topLevelPaths { + guard let path = watch(topLevelPath) else { continue } + read(path, recursiveConfigPaths: &recursiveConfigPaths) + } + var visitedIncludes = Set() + while !recursiveConfigPaths.isEmpty { + let include = recursiveConfigPaths.removeFirst() + guard let path = watch(include), + visitedIncludes.insert(path).inserted else { continue } + read(path, recursiveConfigPaths: &recursiveConfigPaths) + } + + let userThemesDirectory = (Self.standardizedPath(configHomeDirectory) as NSString) + .appendingPathComponent("ghostty/themes") + for themeValue in themeValues { + for themePath in Self.userThemePaths( + forThemeValue: themeValue, + userThemesDirectory: userThemesDirectory + ) { + guard let path = watch(themePath) else { continue } + if contentsByPath[path] == nil, + let contents = fileReader.contents(atPath: path) { + contentsByPath[path] = contents + } + } + } + + return GhosttyConfigLiveReloadSnapshot( + watchedPaths: watchedPaths, + contentsByPath: contentsByPath + ) + } + + /// The user-owned theme files a `theme` value can load: absolute paths as + /// written, and bare names under the user themes directory. + static func userThemePaths( + forThemeValue themeValue: String, + userThemesDirectory: String + ) -> [String] { + var names: [String] = [] + for scheme in [GhosttyConfig.ColorSchemePreference.light, .dark] { + guard let name = GhosttyConfig.appliedThemeName( + from: themeValue, + preferredColorScheme: scheme + ), !names.contains(name) else { continue } + names.append(name) + } + return names.map { name in + let expanded = NSString(string: name).expandingTildeInPath + if (expanded as NSString).isAbsolutePath { + return expanded + } + return (userThemesDirectory as NSString).appendingPathComponent(name) + } + } + + private static func standardizedPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return (NSString(string: trimmed).expandingTildeInPath as NSString).standardizingPath + } + + private static func uniqueStandardizedPaths(_ paths: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for path in paths { + let standardized = standardizedPath(path) + guard !standardized.isEmpty, seen.insert(standardized).inserted else { continue } + result.append(standardized) + } + return result + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadCoordinator.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadCoordinator.swift new file mode 100644 index 000000000000..f377ae887af3 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadCoordinator.swift @@ -0,0 +1,213 @@ +public import CmuxFoundation + +/// Reloads the Ghostty configuration when the user edits a config file. +/// +/// The coordinator watches every file ``GhosttyConfigLiveReloadSnapshotReading`` +/// reports (top-level configs, `config-file` includes, user theme files). A +/// change marks a reload pending and arms a trailing debounce; each further +/// change restarts it, so an editor's write-rename-chmod burst produces one +/// evaluation. The evaluation reads a fresh snapshot off the main thread and +/// calls `reload` only when file contents differ from the last applied +/// snapshot. When the set of reachable files changes (an include or theme was +/// added), the watchers are re-armed on the new set. +/// +/// Reloads cmux starts itself (Reload Configuration, `cmux themes set`, +/// Settings) call ``noteConfigurationDidReload()``, which refreshes the +/// baseline so the watcher does not issue a second, redundant reload for +/// cmux's own writes. A baseline refresh is skipped while a user change is +/// pending, so an edit that lands during a cmux reload is never absorbed. +/// +/// All state is serialized through one operation queue on the main actor; +/// file I/O happens in the injected reader and change source. +/// +/// ```swift +/// let coordinator = GhosttyConfigLiveReloadCoordinator( +/// snapshotReader: reader, +/// changeSource: FileWatcherGhosttyConfigChangeSource() +/// ) { +/// GhosttyApp.shared.reloadConfiguration(source: "ghosttyConfigFileWatcher") +/// } +/// coordinator.start() +/// ``` +@MainActor +public final class GhosttyConfigLiveReloadCoordinator { + /// The default trailing debounce between the last file event and the + /// evaluation. + public nonisolated static let defaultDebounce: Duration = .milliseconds(300) + + /// Emits one element each time the coordinator finishes an operation. + /// Observers (and tests) use it to follow the watcher without polling. + public let outcomes: AsyncStream + + private enum Operation { + case recordBaseline(afterExternalReload: Bool) + case evaluateChange + } + + private let snapshotReader: any GhosttyConfigLiveReloadSnapshotReading + private let changeSource: any GhosttyConfigChangeSource + private let debounce: Duration + private let clock: any FileWatchClock + private let reload: @MainActor () -> Void + private let outcomeContinuation: AsyncStream.Continuation + private let operationContinuation: AsyncStream.Continuation + private let operations: AsyncStream + + private var baseline: GhosttyConfigLiveReloadSnapshot? + private var armedPaths: [String]? + private var subscription: GhosttyConfigChangeSubscription? + private var forwardingTask: Task? + private var operationTask: Task? + private var debounceTask: Task? + private var hasPendingChange = false + private var isStarted = false + private var isStopped = false + + /// Creates a stopped coordinator. Call ``start()`` to begin watching. + /// + /// - Parameters: + /// - snapshotReader: Reads watch paths and file contents off the main + /// thread. + /// - changeSource: Watches paths for changes. + /// - debounce: Trailing quiet period before an evaluation. Defaults to + /// ``defaultDebounce``. + /// - clock: Drives the debounce. Defaults to ``SystemFileWatchClock``; + /// tests inject a clock they release by hand. + /// - reload: Applies the configuration. Called on the main actor only + /// when file contents changed. + public init( + snapshotReader: any GhosttyConfigLiveReloadSnapshotReading, + changeSource: any GhosttyConfigChangeSource, + debounce: Duration = GhosttyConfigLiveReloadCoordinator.defaultDebounce, + clock: any FileWatchClock = SystemFileWatchClock(), + reload: @escaping @MainActor () -> Void + ) { + self.snapshotReader = snapshotReader + self.changeSource = changeSource + self.debounce = debounce + self.clock = clock + self.reload = reload + let (outcomes, outcomeContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(32) + ) + self.outcomes = outcomes + self.outcomeContinuation = outcomeContinuation + let (operations, operationContinuation) = AsyncStream.makeStream() + self.operations = operations + self.operationContinuation = operationContinuation + } + + /// Records the initial baseline and starts watching. Idempotent. + public func start() { + guard !isStarted, !isStopped else { return } + isStarted = true + let operations = self.operations + operationTask = Task { [weak self] in + for await operation in operations { + guard let self else { return } + await self.perform(operation) + } + } + operationContinuation.yield(.recordBaseline(afterExternalReload: false)) + } + + /// Tells the coordinator that the configuration was reloaded by some + /// other path, so the files it read become the new baseline. + public func noteConfigurationDidReload() { + guard isStarted, !isStopped, !hasPendingChange else { return } + operationContinuation.yield(.recordBaseline(afterExternalReload: true)) + } + + /// Stops watching and finishes ``outcomes``. Idempotent. + public func stop() { + guard !isStopped else { return } + isStopped = true + debounceTask?.cancel() + debounceTask = nil + forwardingTask?.cancel() + forwardingTask = nil + operationTask?.cancel() + operationTask = nil + operationContinuation.finish() + outcomeContinuation.finish() + if let subscription { + self.subscription = nil + Task { await subscription.cancel() } + } + } + + // MARK: - Private + + private func noteFileChange() { + guard !isStopped else { return } + hasPendingChange = true + debounceTask?.cancel() + let clock = self.clock + let debounce = self.debounce + // Bounded, cancellable trailing debounce behind the injected clock seam: + // each new event cancels and re-arms it; stop() cancels it. + debounceTask = Task { [weak self] in + do { + try await clock.sleep(for: debounce) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.operationContinuation.yield(.evaluateChange) + } + } + + private func perform(_ operation: Operation) async { + guard !isStopped else { return } + switch operation { + case .recordBaseline(let afterExternalReload): + if afterExternalReload, hasPendingChange { + outcomeContinuation.yield(.baselineSkippedForPendingChange) + return + } + let snapshot = await snapshotReader.snapshot() + guard !isStopped else { return } + if afterExternalReload, hasPendingChange { + outcomeContinuation.yield(.baselineSkippedForPendingChange) + return + } + baseline = snapshot + await arm(paths: snapshot.watchedPaths) + outcomeContinuation.yield(.baselineRecorded) + case .evaluateChange: + hasPendingChange = false + let snapshot = await snapshotReader.snapshot() + guard !isStopped else { return } + let changed = baseline.map { !$0.hasSameContents(as: snapshot) } ?? true + baseline = snapshot + if changed { + reload() + } + await arm(paths: snapshot.watchedPaths) + outcomeContinuation.yield(changed ? .reloaded : .unchanged) + } + } + + private func arm(paths: [String]) async { + guard paths != armedPaths else { return } + armedPaths = paths + forwardingTask?.cancel() + forwardingTask = nil + if let previous = subscription { + subscription = nil + await previous.cancel() + } + let next = await changeSource.subscribe(toPaths: paths) + guard !isStopped else { + await next.cancel() + return + } + subscription = next + forwardingTask = Task { [weak self] in + for await _ in next.events { + guard let self else { return } + self.noteFileChange() + } + } + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadOutcome.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadOutcome.swift new file mode 100644 index 000000000000..bd4c45c79844 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadOutcome.swift @@ -0,0 +1,13 @@ +/// What one ``GhosttyConfigLiveReloadCoordinator`` operation did. +public enum GhosttyConfigLiveReloadOutcome: Equatable, Sendable { + /// A snapshot became the baseline (at start, or after a reload cmux + /// started itself) and the watchers were armed on its paths. + case baselineRecorded + /// A baseline refresh after an external reload was dropped because a + /// file change is still pending evaluation. + case baselineSkippedForPendingChange + /// File contents changed, so the configuration was reloaded. + case reloaded + /// A file event fired but no file contents changed, so nothing reloaded. + case unchanged +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshot.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshot.swift new file mode 100644 index 000000000000..d42e17ac252d --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshot.swift @@ -0,0 +1,45 @@ +/// One point-in-time read of every file that feeds cmux's Ghostty configuration. +/// +/// ``GhosttyConfigLiveReloadCoordinator`` compares snapshots to decide whether +/// a filesystem event changed what Ghostty would load. A directory event, an +/// editor's backup file, or cmux rewriting a file with identical bytes leaves +/// ``contentsByPath`` unchanged, so no reload is issued. +/// +/// ```swift +/// let snapshot = GhosttyConfigDiscovery().liveReloadSnapshot( +/// topLevelPaths: ["/Users/me/.config/ghostty/config"], +/// configHomeDirectory: "/Users/me/.config" +/// ) +/// ``` +public struct GhosttyConfigLiveReloadSnapshot: Equatable, Sendable { + /// Every path to watch, in discovery order and without duplicates. + /// + /// Includes top-level config candidates that do not exist yet, so creating + /// one is noticed, plus `config-file` includes and user theme files. + public let watchedPaths: [String] + + /// UTF-8 contents of each watched path that could be read. + /// + /// A path that is missing or unreadable has no entry, so creating or + /// deleting a file changes this value. + public let contentsByPath: [String: String] + + /// Creates a snapshot. + /// + /// - Parameters: + /// - watchedPaths: The ordered, de-duplicated paths to watch. + /// - contentsByPath: The contents of every readable watched path. + public init(watchedPaths: [String], contentsByPath: [String: String]) { + self.watchedPaths = watchedPaths + self.contentsByPath = contentsByPath + } + + /// Whether `other` has the same file contents, ignoring the watched path + /// list (a missing include does not change what Ghostty loads). + /// + /// - Parameter other: The snapshot to compare against. + /// - Returns: `true` when every readable file has identical contents. + public func hasSameContents(as other: GhosttyConfigLiveReloadSnapshot) -> Bool { + contentsByPath == other.contentsByPath + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotReading.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotReading.swift new file mode 100644 index 000000000000..b843e79c48e0 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotReading.swift @@ -0,0 +1,10 @@ +/// Reads a ``GhosttyConfigLiveReloadSnapshot`` of the user's Ghostty config +/// files for ``GhosttyConfigLiveReloadCoordinator``. +/// +/// Conformers perform file I/O. The requirement is `async` and nonisolated so +/// the coordinator, which runs on the main actor, awaits it without blocking +/// the main thread. +public protocol GhosttyConfigLiveReloadSnapshotReading: Sendable { + /// Returns the current watch paths and file contents. + func snapshot() async -> GhosttyConfigLiveReloadSnapshot +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticePolicyTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticePolicyTests.swift new file mode 100644 index 000000000000..fa00a3a0fedf --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigDiagnostics/GhosttyConfigDiagnosticsNoticePolicyTests.swift @@ -0,0 +1,113 @@ +import Testing +@testable import CmuxTerminalCore + +@Suite struct GhosttyConfigDiagnosticTests { + @Test func parsesFileAndLineFromGhosttyFormat() { + let diagnostic = GhosttyConfigDiagnostic( + message: "/Users/me/.config/ghostty/config:12:font-sise: unknown field" + ) + + #expect(diagnostic.filePath == "/Users/me/.config/ghostty/config") + #expect(diagnostic.line == 12) + #expect(!diagnostic.isFromCmuxInlineConfig) + } + + @Test func pathContainingColonKeepsWholePath() { + let diagnostic = GhosttyConfigDiagnostic(message: "/tmp/a:b/config:3:theme: theme \"X\" not found") + + #expect(diagnostic.filePath == "/tmp/a:b/config") + #expect(diagnostic.line == 3) + } + + @Test func messageWithoutFileLocationHasNoPath() { + let diagnostic = GhosttyConfigDiagnostic(message: "cli:1: invalid value") + + #expect(diagnostic.filePath == nil) + #expect(diagnostic.line == nil) + } + + @Test func recognizesCmuxInlineFragments() { + let diagnostic = GhosttyConfigDiagnostic( + message: "/__cmux_inline__/cmux-renderer-bg.conf:1:macos-background-from-layer: unknown field" + ) + + #expect(diagnostic.isFromCmuxInlineConfig) + } +} + +@Suite struct GhosttyConfigDiagnosticsNoticePolicyTests { + private let unknownField = "/u/.config/ghostty/config:3:font-sise: unknown field" + private let badTheme = "/u/.config/ghostty/config:9:theme: theme \"Nope\" not found" + + @Test func cleanLoadPresentsNothing() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + + #expect(policy.decision(forMessages: []) == .unchanged) + } + + @Test func presentsNewErrorsOnceAndStaysQuietOnRepeatedReloads() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + + guard case .present(let notice) = policy.decision(forMessages: [unknownField, badTheme]) else { + Issue.record("expected the first load with errors to present a notice") + return + } + #expect(notice.totalCount == 2) + #expect(notice.listedDiagnostics.map(\.message) == [unknownField, badTheme]) + #expect(notice.firstFilePath == "/u/.config/ghostty/config") + + // Appearance changes, font zoom, and unrelated edits reload the same errors. + #expect(policy.decision(forMessages: [unknownField, badTheme]) == .unchanged) + #expect(policy.decision(forMessages: [badTheme, unknownField]) == .unchanged) + } + + @Test func changedErrorSetPresentsAgain() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + _ = policy.decision(forMessages: [unknownField]) + + let decision = policy.decision(forMessages: [unknownField, badTheme]) + + #expect(decision == .present(GhosttyConfigDiagnosticsNotice( + listedDiagnostics: [unknownField, badTheme].map(GhosttyConfigDiagnostic.init(message:)), + totalCount: 2 + ))) + } + + @Test func fixingErrorsDismissesAndReintroducingPresentsAgain() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + _ = policy.decision(forMessages: [unknownField]) + + #expect(policy.decision(forMessages: []) == .dismiss) + #expect(policy.decision(forMessages: []) == .unchanged) + guard case .present = policy.decision(forMessages: [unknownField]) else { + Issue.record("a reintroduced error must be reported again") + return + } + } + + @Test func dropsCmuxInlineAndDuplicateDiagnostics() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + let inline = "/__cmux_inline__/cmux-shell-integration.conf:1:shell-integration: invalid value" + + #expect(policy.decision(forMessages: [inline]) == .unchanged) + let decision = policy.decision(forMessages: [unknownField, inline, unknownField, " "]) + + #expect(decision == .present(GhosttyConfigDiagnosticsNotice( + listedDiagnostics: [GhosttyConfigDiagnostic(message: unknownField)], + totalCount: 1 + ))) + } + + @Test func listsAtMostThreeAndCountsTheRest() { + var policy = GhosttyConfigDiagnosticsNoticePolicy() + let messages = (1...5).map { "/u/.config/ghostty/config:\($0):key\($0): unknown field" } + + guard case .present(let notice) = policy.decision(forMessages: messages) else { + Issue.record("expected a notice") + return + } + #expect(notice.listedDiagnostics.count == GhosttyConfigDiagnosticsNoticePolicy.maximumListedDiagnostics) + #expect(notice.totalCount == 5) + #expect(notice.unlistedCount == 2) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/FileWatcherGhosttyConfigChangeSourceTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/FileWatcherGhosttyConfigChangeSourceTests.swift new file mode 100644 index 000000000000..05cda20c03ed --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/FileWatcherGhosttyConfigChangeSourceTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import CmuxTerminalCore + +/// Exercises the production change source against real temporary files, with +/// the save patterns editors use for config files. +@Suite(.timeLimit(.minutes(1))) struct FileWatcherGhosttyConfigChangeSourceTests { + private func makeDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ghostty-config-watch-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// Awaits the next change, bounded so a broken watcher fails instead of + /// hanging CI. + private func nextChange( + _ subscription: GhosttyConfigChangeSubscription, + within seconds: Double = 5 + ) async -> Bool { + await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = subscription.events.makeAsyncIterator() + return await iterator.next() != nil + } + group.addTask { + try? await Task.sleep(for: .seconds(seconds)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + } + + @Test func moveAsideAndRewriteSaveKeepsReportingChanges() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let config = directory.appendingPathComponent("config") + try "font-size = 13\n".write(to: config, atomically: false, encoding: .utf8) + + let subscription = await FileWatcherGhosttyConfigChangeSource() + .subscribe(toPaths: [config.path]) + + // Vim's default save: rename the original to a backup, write a new file. + let backup = directory.appendingPathComponent("config~") + try FileManager.default.moveItem(at: config, to: backup) + try "font-size = 14\n".write(to: config, atomically: false, encoding: .utf8) + #expect(await nextChange(subscription)) + + // The watch followed the new inode: a later in-place write still reports. + try "font-size = 15\n".write(to: config, atomically: false, encoding: .utf8) + #expect(await nextChange(subscription)) + + await subscription.cancel() + } + + @Test func includeCreatedAfterSubscribingReportsChange() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let include = directory.appendingPathComponent("nested/colors.conf") + + let subscription = await FileWatcherGhosttyConfigChangeSource() + .subscribe(toPaths: [include.path]) + + try FileManager.default.createDirectory( + at: include.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "background = #000000\n".write(to: include, atomically: true, encoding: .utf8) + #expect(await nextChange(subscription)) + + await subscription.cancel() + } + + @Test func cancelFinishesEvents() async throws { + let directory = try makeDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let subscription = await FileWatcherGhosttyConfigChangeSource() + .subscribe(toPaths: [directory.appendingPathComponent("config").path]) + var iterator = subscription.events.makeAsyncIterator() + + await subscription.cancel() + + let next: Void? = await iterator.next() + #expect(next == nil) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadCoordinatorTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadCoordinatorTests.swift new file mode 100644 index 000000000000..e6624982717e --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadCoordinatorTests.swift @@ -0,0 +1,290 @@ +import CmuxFoundation +import Testing +@testable import CmuxTerminalCore + +/// Snapshot reader whose result the test replaces between file events. +private actor ScriptedSnapshotReader: GhosttyConfigLiveReloadSnapshotReading { + private var current: GhosttyConfigLiveReloadSnapshot + private(set) var readCount = 0 + + init(_ initial: GhosttyConfigLiveReloadSnapshot) { + current = initial + } + + func set(_ snapshot: GhosttyConfigLiveReloadSnapshot) { + current = snapshot + } + + func snapshot() async -> GhosttyConfigLiveReloadSnapshot { + readCount += 1 + return current + } +} + +/// Change source whose events the test yields by hand. +private actor ManualChangeSource: GhosttyConfigChangeSource { + private(set) var subscribedPathSets: [[String]] = [] + private(set) var cancelledCount = 0 + private var continuations: [AsyncStream.Continuation] = [] + + func subscribe(toPaths paths: [String]) async -> GhosttyConfigChangeSubscription { + subscribedPathSets.append(paths) + let (events, continuation) = AsyncStream.makeStream() + continuations.append(continuation) + return GhosttyConfigChangeSubscription(events: events) { + await self.recordCancel() + continuation.finish() + } + } + + /// Simulates a filesystem event on the newest subscription. + func emitChange() { + continuations.last?.yield(()) + } + + private func recordCancel() { + cancelledCount += 1 + } +} + +/// Debounce clock that returns immediately. +private struct ImmediateClock: FileWatchClock { + func sleep(for _: Duration) async throws { + try Task.checkCancellation() + } +} + +/// Debounce clock that holds every sleeper until the test releases them. +private actor GatedClock: FileWatchClock { + nonisolated let sleepStarted: AsyncStream + private let sleepStartedContinuation: AsyncStream.Continuation + private var waiters: [CheckedContinuation] = [] + + init() { + let (stream, continuation) = AsyncStream.makeStream() + sleepStarted = stream + sleepStartedContinuation = continuation + } + + func sleep(for _: Duration) async throws { + await withCheckedContinuation { continuation in + waiters.append(continuation) + sleepStartedContinuation.yield(()) + } + } + + func releaseAll() { + let released = waiters + waiters = [] + for waiter in released { + waiter.resume() + } + } +} + +@MainActor +private final class ReloadCounter { + var count = 0 +} + +private extension GhosttyConfigLiveReloadSnapshot { + static func fixture( + paths: [String] = ["/cfg/config", "/cfg/config.ghostty"], + contents: [String: String] + ) -> GhosttyConfigLiveReloadSnapshot { + GhosttyConfigLiveReloadSnapshot(watchedPaths: paths, contentsByPath: contents) + } +} + +@MainActor +@Suite(.timeLimit(.minutes(1))) struct GhosttyConfigLiveReloadCoordinatorTests { + private let original = GhosttyConfigLiveReloadSnapshot.fixture(contents: ["/cfg/config": "font-size = 13\n"]) + private let edited = GhosttyConfigLiveReloadSnapshot.fixture(contents: ["/cfg/config": "font-size = 15\n"]) + + private func makeCoordinator( + reader: ScriptedSnapshotReader, + source: ManualChangeSource, + clock: any FileWatchClock = ImmediateClock(), + counter: ReloadCounter + ) -> GhosttyConfigLiveReloadCoordinator { + GhosttyConfigLiveReloadCoordinator( + snapshotReader: reader, + changeSource: source, + debounce: .milliseconds(300), + clock: clock + ) { + counter.count += 1 + } + } + + @Test func startRecordsBaselineAndWatchesEveryResolvedPath() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + + coordinator.start() + + #expect(await outcomes.next() == .baselineRecorded) + #expect(await source.subscribedPathSets == [["/cfg/config", "/cfg/config.ghostty"]]) + #expect(counter.count == 0) + coordinator.stop() + } + + @Test func contentChangeReloadsOnce() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + await reader.set(edited) + await source.emitChange() + + #expect(await outcomes.next() == .reloaded) + #expect(counter.count == 1) + coordinator.stop() + } + + @Test func eventWithoutContentChangeDoesNotReload() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + // A directory event or an editor's swap file: nothing Ghostty reads changed. + await source.emitChange() + + #expect(await outcomes.next() == .unchanged) + #expect(counter.count == 0) + coordinator.stop() + } + + @Test func burstOfEventsIsDebouncedIntoOneEvaluation() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let clock = GatedClock() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, clock: clock, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + var sleeps = clock.sleepStarted.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + // An atomic save: write temp file, rename over the config, touch dir. + await reader.set(edited) + await source.emitChange() + _ = await sleeps.next() + await source.emitChange() + _ = await sleeps.next() + await clock.releaseAll() + + #expect(await outcomes.next() == .reloaded) + #expect(await reader.readCount == 2) + + // The superseded debounce must not have queued a second evaluation: + // the next outcome belongs to the next real edit. + await reader.set(original) + await source.emitChange() + _ = await sleeps.next() + await clock.releaseAll() + + #expect(await outcomes.next() == .reloaded) + #expect(await reader.readCount == 3) + #expect(counter.count == 2) + coordinator.stop() + } + + @Test func reloadCmuxStartedAbsorbsItsOwnFileWrite() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + // `cmux themes set` writes the config and reloads it itself. + await reader.set(edited) + coordinator.noteConfigurationDidReload() + #expect(await outcomes.next() == .baselineRecorded) + + // The watcher's event for that write must not reload a second time. + await source.emitChange() + #expect(await outcomes.next() == .unchanged) + #expect(counter.count == 0) + coordinator.stop() + } + + @Test func pendingUserEditSurvivesAnExternalReload() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let clock = GatedClock() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, clock: clock, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + var sleeps = clock.sleepStarted.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + await reader.set(edited) + await source.emitChange() + _ = await sleeps.next() + // An unrelated reload finishes while the edit is still debouncing; its + // baseline refresh must not swallow the edit. + coordinator.noteConfigurationDidReload() + await clock.releaseAll() + + #expect(await outcomes.next() == .reloaded) + #expect(counter.count == 1) + coordinator.stop() + } + + @Test func rearmsWatchersWhenAnIncludeIsAdded() async { + let reader = ScriptedSnapshotReader(.fixture(paths: ["/cfg/config"], contents: ["/cfg/config": ""])) + let source = ManualChangeSource() + let counter = ReloadCounter() + let coordinator = makeCoordinator(reader: reader, source: source, counter: counter) + var outcomes = coordinator.outcomes.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + await reader.set(.fixture( + paths: ["/cfg/config", "/cfg/colors.conf"], + contents: ["/cfg/config": "config-file = colors.conf\n", "/cfg/colors.conf": "background = #000\n"] + )) + await source.emitChange() + #expect(await outcomes.next() == .reloaded) + #expect(await source.subscribedPathSets == [["/cfg/config"], ["/cfg/config", "/cfg/colors.conf"]]) + #expect(await source.cancelledCount == 1) + + // Events from the re-armed subscription (the include) are delivered. + await reader.set(.fixture( + paths: ["/cfg/config", "/cfg/colors.conf"], + contents: ["/cfg/config": "config-file = colors.conf\n", "/cfg/colors.conf": "background = #111\n"] + )) + await source.emitChange() + #expect(await outcomes.next() == .reloaded) + #expect(counter.count == 2) + coordinator.stop() + } + + @Test func stopFinishesOutcomes() async { + let reader = ScriptedSnapshotReader(original) + let source = ManualChangeSource() + let coordinator = makeCoordinator(reader: reader, source: source, counter: ReloadCounter()) + var outcomes = coordinator.outcomes.makeAsyncIterator() + coordinator.start() + #expect(await outcomes.next() == .baselineRecorded) + + coordinator.stop() + + #expect(await outcomes.next() == nil) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotTests.swift new file mode 100644 index 000000000000..af2cf8133152 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/ConfigLiveReload/GhosttyConfigLiveReloadSnapshotTests.swift @@ -0,0 +1,129 @@ +import CoreText +import Foundation +import Testing +@testable import CmuxTerminalCore + +/// In-memory config files keyed by absolute path. +private struct InMemoryConfigFiles: GhosttyConfigFileReading { + var contentsByPath: [String: String] + + func fileSize(atPath path: String) -> Int? { + contentsByPath[path].map { $0.lengthOfBytes(using: .utf8) } + } + + func contents(atPath path: String) -> String? { + contentsByPath[path] + } +} + +private struct NoFonts: GhosttyFontProbing { + func discoveredFont(named _: String, size _: CGFloat, weightTrait _: CGFloat?) -> CTFont? { nil } + func configuredFont(named _: String, size _: CGFloat) -> CTFont? { nil } +} + +@Suite struct GhosttyConfigLiveReloadSnapshotTests { + private func snapshot( + _ files: [String: String], + topLevelPaths: [String] = ["/home/.config/ghostty/config", "/home/.config/ghostty/config.ghostty"] + ) -> GhosttyConfigLiveReloadSnapshot { + GhosttyConfigDiscovery( + fileReader: InMemoryConfigFiles(contentsByPath: files), + fontProbe: NoFonts() + ).liveReloadSnapshot( + topLevelPaths: topLevelPaths, + configHomeDirectory: "/home/.config", + resolvesSymlinks: false + ) + } + + @Test func watchesMissingTopLevelCandidatesSoCreatingOneIsNoticed() { + let result = snapshot(["/home/.config/ghostty/config": "font-size = 13\n"]) + + #expect(result.watchedPaths == [ + "/home/.config/ghostty/config", + "/home/.config/ghostty/config.ghostty", + ]) + #expect(result.contentsByPath == ["/home/.config/ghostty/config": "font-size = 13\n"]) + } + + @Test func followsRelativeOptionalAndNestedConfigFileIncludes() { + let result = snapshot([ + "/home/.config/ghostty/config": """ + config-file = colors.conf + config-file = ?"/opt/shared/optional.conf" + """, + "/home/.config/ghostty/colors.conf": "config-file = nested/keys.conf\n", + "/home/.config/ghostty/nested/keys.conf": "keybind = cmd+k=clear_screen\n", + ]) + + #expect(result.watchedPaths.contains("/home/.config/ghostty/colors.conf")) + #expect(result.watchedPaths.contains("/opt/shared/optional.conf")) + #expect(result.watchedPaths.contains("/home/.config/ghostty/nested/keys.conf")) + #expect(result.contentsByPath["/home/.config/ghostty/nested/keys.conf"] == "keybind = cmd+k=clear_screen\n") + #expect(result.contentsByPath["/opt/shared/optional.conf"] == nil) + } + + @Test func includeCycleTerminates() { + let result = snapshot([ + "/home/.config/ghostty/config": "config-file = a.conf\n", + "/home/.config/ghostty/a.conf": "config-file = config\n", + ]) + + #expect(result.watchedPaths.filter { $0 == "/home/.config/ghostty/config" }.count == 1) + #expect(result.watchedPaths.contains("/home/.config/ghostty/a.conf")) + } + + @Test func watchesUserThemeFilesForBothSidesOfAConditionalTheme() { + let result = snapshot([ + "/home/.config/ghostty/config": "theme = light:Paper,dark:Night Owl\n", + "/home/.config/ghostty/themes/Night Owl": "background = #011627\n", + ]) + + #expect(result.watchedPaths.contains("/home/.config/ghostty/themes/Paper")) + #expect(result.watchedPaths.contains("/home/.config/ghostty/themes/Night Owl")) + #expect(result.contentsByPath["/home/.config/ghostty/themes/Night Owl"] == "background = #011627\n") + } + + @Test func watchesAbsoluteThemePathAsWritten() { + let result = snapshot([ + "/home/.config/ghostty/config": "theme = /Users/me/themes/custom\n", + ]) + + #expect(result.watchedPaths.contains("/Users/me/themes/custom")) + #expect(!result.watchedPaths.contains { $0.hasSuffix("ghostty/themes//Users/me/themes/custom") }) + } + + @Test func themeEditChangesContentsButNotWatchedPaths() { + let before = snapshot([ + "/home/.config/ghostty/config": "theme = Mine\n", + "/home/.config/ghostty/themes/Mine": "background = #000000\n", + ]) + let after = snapshot([ + "/home/.config/ghostty/config": "theme = Mine\n", + "/home/.config/ghostty/themes/Mine": "background = #111111\n", + ]) + + #expect(before.watchedPaths == after.watchedPaths) + #expect(!before.hasSameContents(as: after)) + } + + @Test func liveReloadTopLevelPathsIncludeLegacyAndCmuxCandidatesThatDoNotExist() { + let discovery = GhosttyConfigDiscovery( + fileReader: InMemoryConfigFiles(contentsByPath: [:]), + fontProbe: NoFonts() + ) + let appSupport = URL(fileURLWithPath: "/nonexistent-cmux-live-reload-test/Application Support") + + let paths = discovery.liveReloadTopLevelPaths( + currentBundleIdentifier: "com.cmuxterm.app.debug.tag", + appSupportDirectory: appSupport + ) + + let base = appSupport.path + #expect(paths.contains("\(base)/com.mitchellh.ghostty/config.ghostty")) + #expect(paths.contains("\(base)/com.mitchellh.ghostty/config")) + #expect(paths.contains("\(base)/com.cmuxterm.app/config.ghostty")) + #expect(paths.contains("\(base)/com.cmuxterm.app.debug.tag/config.ghostty")) + #expect(Set(paths).count == paths.count) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1caad6287a06..74a8f6a2a15f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -558424,6 +558424,301 @@ } } } + }, + "ghosttyConfigDiagnostics.notice.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Dismiss" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تجاهل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descartar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignorer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "ghosttyConfigDiagnostics.notice.hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fix the file and save it; cmux reloads the config automatically." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أصلح الملف واحفظه؛ يعيد cmux تحميل الإعدادات تلقائيًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Korrigiere die Datei und speichere sie; cmux lädt die Konfiguration automatisch neu." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Corrige el archivo y guárdalo; cmux vuelve a cargar la configuración automáticamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Corrigez le fichier et enregistrez-le ; cmux recharge automatiquement la configuration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを修正して保存すると、cmux が設定を自動的に再読み込みします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파일을 수정하고 저장하면 cmux가 설정을 자동으로 다시 불러옵니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修正文件并保存;cmux 会自动重新加载配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修正檔案並儲存;cmux 會自動重新載入設定。" + } + } + } + }, + "ghosttyConfigDiagnostics.notice.more": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More errors are not shown." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تُعرض أخطاء أخرى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weitere Fehler werden nicht angezeigt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hay más errores que no se muestran." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D’autres erreurs ne sont pas affichées." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示されていないエラーが他にもあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시되지 않은 오류가 더 있습니다." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还有其他错误未显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚有其他錯誤未顯示。" + } + } + } + }, + "ghosttyConfigDiagnostics.notice.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Config" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الإعدادات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir la configuration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 열기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟設定" + } + } + } + }, + "ghosttyConfigDiagnostics.notice.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghostty config errors" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أخطاء في إعدادات Ghostty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler in der Ghostty-Konfiguration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Errores en la configuración de Ghostty" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreurs dans la configuration Ghostty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 設定のエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 설정 오류" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 配置错误" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ghostty 設定錯誤" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8eb0cf74b28c..cc53a71c46e6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -884,6 +884,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } }() + /// Reloads the Ghostty config when one of its files changes on disk. + private lazy var ghosttyConfigLiveReloadCoordinator: GhosttyConfigLiveReloadCoordinator = { + GhosttyConfigLiveReloadCoordinator( + snapshotReader: DiscoveryGhosttyConfigLiveReloadSnapshotReader( + currentBundleIdentifier: Bundle.main.bundleIdentifier, + appSupportDirectory: FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first, + configHomeDirectory: DiscoveryGhosttyConfigLiveReloadSnapshotReader.configHomeDirectory( + environment: ProcessInfo.processInfo.environment + ) + ), + changeSource: FileWatcherGhosttyConfigChangeSource() + ) { [weak self] in + _ = self?.reloadConfiguration(source: "ghosttyConfigFileWatcher") + } + }() + /// Shows Ghostty config errors after a load, once per distinct set. + private lazy var ghosttyConfigDiagnosticsNoticePresenter = GhosttyConfigDiagnosticsNoticePresenter() private var splitButtonTooltipRefreshScheduled = false private var didScheduleGhosttyCrashBreadcrumbCheck = false private var ghosttyCrashBreadcrumbTask: Task? @@ -1837,6 +1857,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() + if !isRunningUnderXCTest { + startGhosttyConfigLiveReload() + } installGlobalFontMagnificationObserver() installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() @@ -14260,9 +14283,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent queue: .main ) { [weak self] _ in self?.refreshGhosttyGotoSplitShortcuts() + MainActor.assumeIsolated { + self?.ghosttyConfigDidReloadForLiveReload() + } } } + /// Starts the Ghostty config file watcher and shows diagnostics from the + /// launch-time config load, which ran before the reload observer existed. + private func startGhosttyConfigLiveReload() { + ghosttyConfigLiveReloadCoordinator.start() + ghosttyConfigDiagnosticsNoticePresenter.update( + diagnosticMessages: GhosttyApp.shared.lastLoadedConfigDiagnosticMessages + ) + } + + private func ghosttyConfigDidReloadForLiveReload() { + guard !isRunningUnderXCTestCached else { return } + ghosttyConfigLiveReloadCoordinator.noteConfigurationDidReload() + ghosttyConfigDiagnosticsNoticePresenter.update( + diagnosticMessages: GhosttyApp.shared.lastLoadedConfigDiagnosticMessages + ) + } + private func installGlobalFontMagnificationObserver() { guard globalFontMagnificationObserver == nil else { return } globalFontMagnificationObserver = NotificationCenter.default.addObserver( diff --git a/Sources/GhosttyApp+ConfigDiagnostics.swift b/Sources/GhosttyApp+ConfigDiagnostics.swift new file mode 100644 index 000000000000..38ed80f62179 --- /dev/null +++ b/Sources/GhosttyApp+ConfigDiagnostics.swift @@ -0,0 +1,15 @@ +import Foundation + +extension GhosttyApp { + /// Returns the formatted diagnostics Ghostty collected while loading + /// `config` (`::: ` for file diagnostics). + static func configDiagnosticMessages(_ config: ghostty_config_t) -> [String] { + let count = Int(ghostty_config_diagnostics_count(config)) + guard count > 0 else { return [] } + return (0..