Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// One diagnostic Ghostty reported while loading its configuration.
///
/// Ghostty formats file diagnostics as `<path>:<line>:<key>: <message>`;
/// ``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 ":<digits>:" 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[..<colon]), line)
}
searchStart = digitsStart
}
return (nil, nil)
}
}

private extension Character {
var isASCIIDigit: Bool {
isASCII && isNumber
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/// The content of the notice cmux shows when the Ghostty config has errors.
public struct GhosttyConfigDiagnosticsNotice: Equatable, Sendable {
/// The diagnostics to list, at most
/// ``GhosttyConfigDiagnosticsNoticePolicy/maximumListedDiagnostics``.
public let listedDiagnostics: [GhosttyConfigDiagnostic]
/// The number of distinct user-facing diagnostics, including unlisted ones.
public let totalCount: Int

/// Creates a notice.
///
/// - Parameters:
/// - listedDiagnostics: The diagnostics shown in the notice.
/// - totalCount: The number of distinct diagnostics.
public init(listedDiagnostics: [GhosttyConfigDiagnostic], totalCount: Int) {
self.listedDiagnostics = listedDiagnostics
self.totalCount = totalCount
}

/// How many diagnostics exist beyond the listed ones.
public var unlistedCount: Int {
max(0, totalCount - listedDiagnostics.count)
}

/// The first file a diagnostic points at, for an "Open Config" action.
public var firstFilePath: String? {
listedDiagnostics.lazy.compactMap(\.filePath).first
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// What the app should do with its config-error notice after a config load.
public enum GhosttyConfigDiagnosticsNoticeDecision: Equatable, Sendable {
/// Show (or replace the visible notice with) this notice.
case present(GhosttyConfigDiagnosticsNotice)
/// The errors were fixed; hide a visible notice.
case dismiss
/// Nothing changed since the last decision; leave the notice alone.
case unchanged
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// Decides when a Ghostty config load should surface its diagnostics.
///
/// Every reload (file edits, appearance changes, font zoom) re-reads the same
/// config, so an unfixed error would reappear constantly. The policy presents
/// a set of errors once, stays quiet while the same set persists, presents
/// again when the set changes, and resets after a clean load so a
/// reintroduced error is reported again. Diagnostics from cmux's own inline
/// fragments are not user-actionable and are dropped.
///
/// ```swift
/// var policy = GhosttyConfigDiagnosticsNoticePolicy()
/// switch policy.decision(forMessages: messages) {
/// case .present(let notice): presenter.show(notice)
/// case .dismiss: presenter.hide()
/// case .unchanged: break
/// }
/// ```
public struct GhosttyConfigDiagnosticsNoticePolicy: Sendable {
/// The most diagnostics a notice lists; the rest are summarized as a count.
public static let maximumListedDiagnostics = 3

private var lastPresented: Set<GhosttyConfigDiagnostic>?

/// 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<GhosttyConfigDiagnostic>()
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
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -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<Void>.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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<Void>

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<Void>,
tearDown: @escaping @Sendable () async -> Void
) {
self.events = events
self.tearDown = tearDown
}

/// Stops watching and finishes ``events``. Idempotent.
public func cancel() async {
await tearDown()
}
}
Loading
Loading