Reject invalid color values at parse time with a clear error message instead of silently ignoring them. Colors are normalized to #RRGGBB via WorkspaceTabColorSettings.normalizedHex during decode. Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
617 lines
20 KiB
Swift
617 lines
20 KiB
Swift
import Bonsplit
|
|
import Combine
|
|
import Foundation
|
|
|
|
struct CmuxConfigFile: Codable, Sendable {
|
|
var commands: [CmuxCommandDefinition]
|
|
}
|
|
|
|
struct CmuxCommandDefinition: Codable, Sendable, Identifiable {
|
|
var name: String
|
|
var description: String?
|
|
var keywords: [String]?
|
|
var restart: CmuxRestartBehavior?
|
|
var workspace: CmuxWorkspaceDefinition?
|
|
var command: String?
|
|
var confirm: Bool?
|
|
|
|
var id: String {
|
|
"cmux.config.command." + (name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? name)
|
|
}
|
|
|
|
init(
|
|
name: String,
|
|
description: String? = nil,
|
|
keywords: [String]? = nil,
|
|
restart: CmuxRestartBehavior? = nil,
|
|
workspace: CmuxWorkspaceDefinition? = nil,
|
|
command: String? = nil,
|
|
confirm: Bool? = nil
|
|
) {
|
|
self.name = name
|
|
self.description = description
|
|
self.keywords = keywords
|
|
self.restart = restart
|
|
self.workspace = workspace
|
|
self.command = command
|
|
self.confirm = confirm
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try container.decode(String.self, forKey: .name)
|
|
description = try container.decodeIfPresent(String.self, forKey: .description)
|
|
keywords = try container.decodeIfPresent([String].self, forKey: .keywords)
|
|
restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart)
|
|
workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace)
|
|
command = try container.decodeIfPresent(String.self, forKey: .command)
|
|
confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm)
|
|
|
|
if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Command name must not be blank"
|
|
)
|
|
)
|
|
}
|
|
if let cmd = command,
|
|
cmd.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Command '\(name)' must not define a blank 'command'"
|
|
)
|
|
)
|
|
}
|
|
|
|
if workspace != nil && command != nil {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Command '\(name)' must not define both 'workspace' and 'command'"
|
|
)
|
|
)
|
|
}
|
|
if workspace == nil && command == nil {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Command '\(name)' must define either 'workspace' or 'command'"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CmuxRestartBehavior: String, Codable, Sendable {
|
|
case recreate
|
|
case ignore
|
|
case confirm
|
|
}
|
|
|
|
struct CmuxWorkspaceDefinition: Codable, Sendable {
|
|
var name: String?
|
|
var cwd: String?
|
|
var color: String?
|
|
var layout: CmuxLayoutNode?
|
|
|
|
init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) {
|
|
self.name = name
|
|
self.cwd = cwd
|
|
self.color = color
|
|
self.layout = layout
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try container.decodeIfPresent(String.self, forKey: .name)
|
|
cwd = try container.decodeIfPresent(String.self, forKey: .cwd)
|
|
layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout)
|
|
|
|
if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) {
|
|
guard let normalized = WorkspaceTabColorSettings.normalizedHex(rawColor) else {
|
|
throw DecodingError.dataCorruptedError(
|
|
forKey: .color,
|
|
in: container,
|
|
debugDescription: "Invalid color \"\(rawColor)\". Expected 6-digit hex format: #RRGGBB"
|
|
)
|
|
}
|
|
color = normalized
|
|
} else {
|
|
color = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
indirect enum CmuxLayoutNode: Codable, Sendable {
|
|
case pane(CmuxPaneDefinition)
|
|
case split(CmuxSplitDefinition)
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case pane
|
|
case direction
|
|
case split
|
|
case children
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let hasPane = container.contains(.pane)
|
|
let hasDirection = container.contains(.direction)
|
|
|
|
if hasPane && hasDirection {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "CmuxLayoutNode must not contain both 'pane' and 'direction' keys"
|
|
)
|
|
)
|
|
}
|
|
|
|
if hasPane {
|
|
let pane = try container.decode(CmuxPaneDefinition.self, forKey: .pane)
|
|
self = .pane(pane)
|
|
} else if hasDirection {
|
|
let splitDef = try CmuxSplitDefinition(from: decoder)
|
|
self = .split(splitDef)
|
|
} else {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "CmuxLayoutNode must contain either a 'pane' key or a 'direction' key"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
switch self {
|
|
case .pane(let pane):
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(pane, forKey: .pane)
|
|
case .split(let split):
|
|
try split.encode(to: encoder)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CmuxSplitDefinition: Codable, Sendable {
|
|
var direction: CmuxSplitDirection
|
|
var split: Double?
|
|
var children: [CmuxLayoutNode]
|
|
|
|
init(direction: CmuxSplitDirection, split: Double? = nil, children: [CmuxLayoutNode]) {
|
|
self.direction = direction
|
|
self.split = split
|
|
self.children = children
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
direction = try container.decode(CmuxSplitDirection.self, forKey: .direction)
|
|
split = try container.decodeIfPresent(Double.self, forKey: .split)
|
|
children = try container.decode([CmuxLayoutNode].self, forKey: .children)
|
|
if children.count != 2 {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Split node requires exactly 2 children, got \(children.count)"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
var clampedSplitPosition: Double {
|
|
let value = split ?? 0.5
|
|
return min(0.9, max(0.1, value))
|
|
}
|
|
|
|
var splitOrientation: SplitOrientation {
|
|
switch direction {
|
|
case .horizontal: return .horizontal
|
|
case .vertical: return .vertical
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CmuxSplitDirection: String, Codable, Sendable {
|
|
case horizontal
|
|
case vertical
|
|
}
|
|
|
|
struct CmuxPaneDefinition: Codable, Sendable {
|
|
var surfaces: [CmuxSurfaceDefinition]
|
|
|
|
init(surfaces: [CmuxSurfaceDefinition]) {
|
|
self.surfaces = surfaces
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
surfaces = try container.decode([CmuxSurfaceDefinition].self, forKey: .surfaces)
|
|
if surfaces.isEmpty {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Pane node must contain at least one surface"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CmuxSurfaceDefinition: Codable, Sendable {
|
|
var type: CmuxSurfaceType
|
|
var name: String?
|
|
var command: String?
|
|
var cwd: String?
|
|
var env: [String: String]?
|
|
var url: String?
|
|
var focus: Bool?
|
|
}
|
|
|
|
enum CmuxSurfaceType: String, Codable, Sendable {
|
|
case terminal
|
|
case browser
|
|
}
|
|
|
|
@MainActor
|
|
final class CmuxConfigStore: ObservableObject {
|
|
@Published private(set) var loadedCommands: [CmuxCommandDefinition] = []
|
|
@Published private(set) var configRevision: UInt64 = 0
|
|
|
|
/// Which config file each command came from, keyed by command id.
|
|
private(set) var commandSourcePaths: [String: String] = [:]
|
|
|
|
private(set) var localConfigPath: String?
|
|
let globalConfigPath: String = {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
return (home as NSString).appendingPathComponent(".config/cmux/cmux.json")
|
|
}()
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var localFileWatchSource: DispatchSourceFileSystemObject?
|
|
private var localFileDescriptor: Int32 = -1
|
|
private var globalFileWatchSource: DispatchSourceFileSystemObject?
|
|
private var globalFileDescriptor: Int32 = -1
|
|
private let watchQueue = DispatchQueue(label: "com.cmux.config-file-watch")
|
|
|
|
private static let maxReattachAttempts = 5
|
|
private static let reattachDelay: TimeInterval = 0.5
|
|
|
|
init() {
|
|
startGlobalFileWatcher()
|
|
}
|
|
|
|
deinit {
|
|
localFileWatchSource?.cancel()
|
|
globalFileWatchSource?.cancel()
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func wireDirectoryTracking(tabManager: TabManager) {
|
|
cancellables.removeAll()
|
|
|
|
tabManager.$selectedTabId
|
|
.compactMap { [weak tabManager] tabId -> Workspace? in
|
|
guard let tabId, let tabManager else { return nil }
|
|
return tabManager.tabs.first(where: { $0.id == tabId })
|
|
}
|
|
.removeDuplicates(by: { $0.id == $1.id })
|
|
.map { workspace -> AnyPublisher<String, Never> in
|
|
workspace.$currentDirectory.eraseToAnyPublisher()
|
|
}
|
|
.switchToLatest()
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] directory in
|
|
self?.updateLocalConfigPath(directory)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
if let directory = tabManager.selectedWorkspace?.currentDirectory {
|
|
updateLocalConfigPath(directory)
|
|
}
|
|
}
|
|
|
|
private func updateLocalConfigPath(_ directory: String?) {
|
|
let newPath: String?
|
|
if let directory, !directory.isEmpty {
|
|
newPath = findCmuxConfig(startingFrom: directory)
|
|
?? (directory as NSString).appendingPathComponent("cmux.json")
|
|
} else {
|
|
newPath = nil
|
|
}
|
|
|
|
guard newPath != localConfigPath else { return }
|
|
stopLocalFileWatcher()
|
|
localConfigPath = newPath
|
|
if newPath != nil {
|
|
startLocalFileWatcher()
|
|
}
|
|
loadAll()
|
|
}
|
|
|
|
private func findCmuxConfig(startingFrom directory: String) -> String? {
|
|
var current = directory
|
|
let fs = FileManager.default
|
|
while true {
|
|
let candidate = (current as NSString).appendingPathComponent("cmux.json")
|
|
if fs.fileExists(atPath: candidate) {
|
|
return candidate
|
|
}
|
|
let parent = (current as NSString).deletingLastPathComponent
|
|
if parent == current { break }
|
|
current = parent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadAll() {
|
|
var commands: [CmuxCommandDefinition] = []
|
|
var seenNames = Set<String>()
|
|
var sourcePaths: [String: String] = [:]
|
|
|
|
// Local config takes precedence
|
|
if let localPath = localConfigPath {
|
|
if let localConfig = parseConfig(at: localPath) {
|
|
for command in localConfig.commands {
|
|
if !seenNames.contains(command.name) {
|
|
commands.append(command)
|
|
seenNames.insert(command.name)
|
|
sourcePaths[command.id] = localPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global config fills in the rest
|
|
if let globalConfig = parseConfig(at: globalConfigPath) {
|
|
for command in globalConfig.commands {
|
|
if !seenNames.contains(command.name) {
|
|
commands.append(command)
|
|
seenNames.insert(command.name)
|
|
sourcePaths[command.id] = globalConfigPath
|
|
}
|
|
}
|
|
}
|
|
|
|
loadedCommands = commands
|
|
commandSourcePaths = sourcePaths
|
|
configRevision &+= 1
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
private func parseConfig(at path: String) -> CmuxConfigFile? {
|
|
guard FileManager.default.fileExists(atPath: path),
|
|
let data = FileManager.default.contents(atPath: path),
|
|
!data.isEmpty else {
|
|
return nil
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(CmuxConfigFile.self, from: data)
|
|
} catch {
|
|
NSLog("[CmuxConfig] parse error at %@: %@", path, String(describing: error))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - File watching (local)
|
|
|
|
private func startLocalFileWatcher() {
|
|
guard let path = localConfigPath else { return }
|
|
let fd = open(path, O_EVTONLY)
|
|
guard fd >= 0 else {
|
|
// File doesn't exist yet — watch the directory instead
|
|
startLocalDirectoryWatcher()
|
|
return
|
|
}
|
|
localFileDescriptor = fd
|
|
|
|
let source = DispatchSource.makeFileSystemObjectSource(
|
|
fileDescriptor: fd,
|
|
eventMask: [.write, .delete, .rename, .extend],
|
|
queue: watchQueue
|
|
)
|
|
|
|
source.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
let flags = source.data
|
|
if flags.contains(.delete) || flags.contains(.rename) {
|
|
DispatchQueue.main.async {
|
|
self.stopLocalFileWatcher()
|
|
self.loadAll()
|
|
self.scheduleLocalReattach(attempt: 1)
|
|
}
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
self.loadAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
source.setCancelHandler {
|
|
Darwin.close(fd)
|
|
}
|
|
|
|
source.resume()
|
|
localFileWatchSource = source
|
|
}
|
|
|
|
private func startLocalDirectoryWatcher() {
|
|
guard let path = localConfigPath else { return }
|
|
let dirPath = (path as NSString).deletingLastPathComponent
|
|
let fd = open(dirPath, O_EVTONLY)
|
|
guard fd >= 0 else { return }
|
|
localFileDescriptor = fd
|
|
|
|
let source = DispatchSource.makeFileSystemObjectSource(
|
|
fileDescriptor: fd,
|
|
eventMask: [.write, .link, .rename],
|
|
queue: watchQueue
|
|
)
|
|
|
|
source.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async {
|
|
guard let configPath = self.localConfigPath,
|
|
FileManager.default.fileExists(atPath: configPath) else { return }
|
|
// File appeared — switch to file-level watching
|
|
self.stopLocalFileWatcher()
|
|
self.loadAll()
|
|
self.startLocalFileWatcher()
|
|
}
|
|
}
|
|
|
|
source.setCancelHandler {
|
|
Darwin.close(fd)
|
|
}
|
|
|
|
source.resume()
|
|
localFileWatchSource = source
|
|
}
|
|
|
|
private func scheduleLocalReattach(attempt: Int) {
|
|
guard attempt <= Self.maxReattachAttempts else { return }
|
|
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async {
|
|
guard let path = self.localConfigPath else { return }
|
|
if FileManager.default.fileExists(atPath: path) {
|
|
self.loadAll()
|
|
self.startLocalFileWatcher()
|
|
} else {
|
|
self.startLocalDirectoryWatcher()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopLocalFileWatcher() {
|
|
if let source = localFileWatchSource {
|
|
source.cancel()
|
|
localFileWatchSource = nil
|
|
}
|
|
localFileDescriptor = -1
|
|
}
|
|
|
|
// MARK: - File watching (global)
|
|
|
|
private func startGlobalFileWatcher() {
|
|
let fd = open(globalConfigPath, O_EVTONLY)
|
|
guard fd >= 0 else {
|
|
startGlobalDirectoryWatcher()
|
|
return
|
|
}
|
|
globalFileDescriptor = fd
|
|
|
|
let source = DispatchSource.makeFileSystemObjectSource(
|
|
fileDescriptor: fd,
|
|
eventMask: [.write, .delete, .rename, .extend],
|
|
queue: watchQueue
|
|
)
|
|
|
|
source.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
let flags = source.data
|
|
if flags.contains(.delete) || flags.contains(.rename) {
|
|
DispatchQueue.main.async {
|
|
self.stopGlobalFileWatcher()
|
|
self.loadAll()
|
|
self.scheduleGlobalReattach(attempt: 1)
|
|
}
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
self.loadAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
source.setCancelHandler {
|
|
Darwin.close(fd)
|
|
}
|
|
|
|
source.resume()
|
|
globalFileWatchSource = source
|
|
}
|
|
|
|
private func scheduleGlobalReattach(attempt: Int) {
|
|
guard attempt <= Self.maxReattachAttempts else {
|
|
startGlobalDirectoryWatcher()
|
|
return
|
|
}
|
|
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async {
|
|
if FileManager.default.fileExists(atPath: self.globalConfigPath) {
|
|
self.loadAll()
|
|
self.startGlobalFileWatcher()
|
|
} else {
|
|
self.scheduleGlobalReattach(attempt: attempt + 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startGlobalDirectoryWatcher() {
|
|
let dirPath = (globalConfigPath as NSString).deletingLastPathComponent
|
|
let fm = FileManager.default
|
|
if !fm.fileExists(atPath: dirPath) {
|
|
try? fm.createDirectory(atPath: dirPath, withIntermediateDirectories: true)
|
|
}
|
|
let fd = open(dirPath, O_EVTONLY)
|
|
guard fd >= 0 else { return }
|
|
globalFileDescriptor = fd
|
|
|
|
let source = DispatchSource.makeFileSystemObjectSource(
|
|
fileDescriptor: fd,
|
|
eventMask: [.write, .link, .rename],
|
|
queue: watchQueue
|
|
)
|
|
|
|
source.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async {
|
|
guard FileManager.default.fileExists(atPath: self.globalConfigPath) else { return }
|
|
self.stopGlobalFileWatcher()
|
|
self.loadAll()
|
|
self.startGlobalFileWatcher()
|
|
}
|
|
}
|
|
|
|
source.setCancelHandler {
|
|
Darwin.close(fd)
|
|
}
|
|
|
|
source.resume()
|
|
globalFileWatchSource = source
|
|
}
|
|
|
|
private func stopGlobalFileWatcher() {
|
|
if let source = globalFileWatchSource {
|
|
source.cancel()
|
|
globalFileWatchSource = nil
|
|
}
|
|
globalFileDescriptor = -1
|
|
}
|
|
}
|
|
|
|
extension CmuxConfigStore {
|
|
static func resolveCwd(_ cwd: String?, relativeTo baseCwd: String) -> String {
|
|
guard let cwd, !cwd.isEmpty, cwd != "." else {
|
|
return baseCwd
|
|
}
|
|
if cwd.hasPrefix("~/") || cwd == "~" {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
if cwd == "~" { return home }
|
|
return (home as NSString).appendingPathComponent(String(cwd.dropFirst(2)))
|
|
}
|
|
if cwd.hasPrefix("/") {
|
|
return cwd
|
|
}
|
|
return (baseCwd as NSString).appendingPathComponent(cwd)
|
|
}
|
|
}
|