cmux/Sources/cmuxApp.swift
Lawrence Chen 7d6f33c143
Sidebar status as text + detect git HEAD changes instantly (#30)
* Sidebar status as text + detect git HEAD changes instantly

- Replace sidebar status pills with plain text + show more/less toggle
  for a cleaner, more readable sidebar layout
- Watch .git/HEAD mtime in zsh precmd to detect branch changes from
  aliases (gco), tools (gh pr checkout), etc. without waiting for the
  3s polling interval
- Fix NSImage shared instance mutation in DraggableFolderNSView by
  copying before resizing to prevent layout side-effects
- Fix set_status --tab flag being swallowed by -- stop token via
  new parseOptionsNoStop parser
- Update sidebar test to cover alias-based branch switching

* Append status text to notification body automatically

When creating notifications, include the tab's current status entries
in the notification body so users see context (e.g. git branch, ports)
alongside the notification message.

* Add screenshot to README
2026-02-09 14:18:33 -08:00

1096 lines
40 KiB
Swift

import AppKit
import SwiftUI
import Darwin
@main
struct cmuxApp: App {
@StateObject private var tabManager = TabManager()
@StateObject private var notificationStore = TerminalNotificationStore.shared
@StateObject private var sidebarState = SidebarState()
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
configureGhosttyEnvironment()
// Start the terminal controller for programmatic control
// This runs after TabManager is created via @StateObject
let defaults = UserDefaults.standard
if defaults.object(forKey: SocketControlSettings.appStorageKey) == nil,
let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
defaults.set(legacy ? SocketControlMode.full.rawValue : SocketControlMode.off.rawValue,
forKey: SocketControlSettings.appStorageKey)
}
}
private func configureGhosttyEnvironment() {
let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
var resolvedResourcesDir: String?
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
if let bundledGhosttyURL,
fileManager.fileExists(atPath: bundledGhosttyURL.path),
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
resolvedResourcesDir = bundledGhosttyURL.path
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
resolvedResourcesDir = ghosttyAppResources
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
resolvedResourcesDir = bundledGhosttyURL.path
}
if let resolvedResourcesDir {
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
}
}
if getenv("TERM") == nil {
setenv("TERM", "xterm-ghostty", 1)
}
if getenv("TERM_PROGRAM") == nil {
setenv("TERM_PROGRAM", "ghostty", 1)
}
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
let resourcesURL = URL(fileURLWithPath: resourcesDir)
let resourcesParent = resourcesURL.deletingLastPathComponent()
let dataDir = resourcesParent.path
let manDir = resourcesParent.appendingPathComponent("man").path
let bundledTerminfoDir = resourcesURL.appendingPathComponent("terminfo").path
let siblingTerminfoDir = resourcesParent.appendingPathComponent("terminfo").path
appendEnvPathIfMissing(
"XDG_DATA_DIRS",
path: dataDir,
defaultValue: "/usr/local/share:/usr/share"
)
appendEnvPathIfMissing("MANPATH", path: manDir)
// Ensure `TERM=xterm-ghostty` works even when launching from Finder
// (no shell to pre-seed TERMINFO). Prefer a terminfo directory next
// to the configured ghostty resources, but fall back to the sibling
// layout used by the Ghostty.app bundle.
if getenv("TERMINFO") == nil {
if fileManager.fileExists(atPath: bundledTerminfoDir) {
setenv("TERMINFO", bundledTerminfoDir, 1)
} else if fileManager.fileExists(atPath: siblingTerminfoDir) {
setenv("TERMINFO", siblingTerminfoDir, 1)
}
}
}
}
private func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) {
if path.isEmpty { return }
var current = getenv(key).flatMap { String(cString: $0) } ?? ""
if current.isEmpty, let defaultValue {
current = defaultValue
}
if current.split(separator: ":").contains(Substring(path)) {
return
}
let updated = current.isEmpty ? path : "\(current):\(path)"
setenv(key, updated, 1)
}
var body: some Scene {
WindowGroup {
ContentView(updateViewModel: appDelegate.updateViewModel)
.environmentObject(tabManager)
.environmentObject(notificationStore)
.environmentObject(sidebarState)
.onAppear {
// Start the Unix socket controller for programmatic access
updateSocketController()
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
applyAppearance()
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
DispatchQueue.main.async {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
}
.onChange(of: appearanceMode) { _ in
applyAppearance()
}
.onChange(of: socketControlMode) { _ in
updateSocketController()
}
}
.windowStyle(.hiddenTitleBar)
Settings {
SettingsRootView()
}
.defaultSize(width: 460, height: 360)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About cmuxterm") {
showAboutPanel()
}
Button("Ghostty Settings…") {
GhosttyApp.shared.openConfigurationInTextEdit()
}
Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration()
}
.keyboardShortcut("r", modifiers: [.command, .shift])
Divider()
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
}
}
#if DEBUG
CommandMenu("Update Pill") {
Button("Show Update Pill") {
appDelegate.showUpdatePill(nil)
}
Button("Show Loading State") {
appDelegate.showUpdatePillLoading(nil)
}
Button("Hide Update Pill") {
appDelegate.hideUpdatePill(nil)
}
Button("Automatic Update Pill") {
appDelegate.clearUpdatePillOverride(nil)
}
}
#endif
CommandMenu("Update Logs") {
Button("Copy Update Logs") {
appDelegate.copyUpdateLogs(nil)
}
Button("Copy Focus Logs") {
appDelegate.copyFocusLogs(nil)
}
}
#if DEBUG
CommandMenu("Debug") {
Button("New Tab With Lorem Search Text") {
appDelegate.openDebugLoremTab(nil)
}
Button("New Tab With Large Scrollback") {
appDelegate.openDebugScrollbackTab(nil)
}
Divider()
Button("Sidebar Debug…") {
SidebarDebugWindowController.shared.show()
}
Button("Background Debug…") {
BackgroundDebugWindowController.shared.show()
}
Divider()
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
ForEach(TitlebarControlsStyle.allCases) { style in
Text(style.menuTitle).tag(style.rawValue)
}
}
Divider()
Button("Trigger Sentry Test Crash") {
appDelegate.triggerSentryTestCrash(nil)
}
}
#endif
// New tab commands
CommandGroup(replacing: .newItem) {
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("t", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("n", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("`", modifiers: [.control, .shift])
}
// Close tab
CommandGroup(after: .newItem) {
Button("Close Panel") {
closePanelOrWindow()
}
.keyboardShortcut("w", modifiers: .command)
Button("Close Tab") {
tabManager.closeCurrentTabWithConfirmation()
}
.keyboardShortcut("w", modifiers: [.command, .shift])
}
// Find
CommandGroup(after: .textEditing) {
Menu("Find") {
Button("Find…") {
tabManager.startSearch()
}
.keyboardShortcut("f", modifiers: .command)
Button("Find Next") {
tabManager.findNext()
}
.keyboardShortcut("g", modifiers: .command)
Button("Find Previous") {
tabManager.findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
Button("Hide Find Bar") {
tabManager.hideFind()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!tabManager.isFindVisible)
Divider()
Button("Use Selection for Find") {
tabManager.searchSelection()
}
.keyboardShortcut("e", modifiers: .command)
.disabled(!tabManager.canUseSelectionForFind)
}
}
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") {
sidebarState.toggle()
}
.keyboardShortcut("b", modifiers: .command)
Divider()
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut("]", modifiers: [.command, .shift])
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut("[", modifiers: [.command, .shift])
Button("Back") {
tabManager.navigateBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
tabManager.navigateForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut(.tab, modifiers: .control)
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut(.tab, modifiers: [.control, .shift])
Divider()
// Cmd+1 through Cmd+9 for tab selection
ForEach(1...9, id: \.self) { number in
Button("Tab \(number)") {
if number == 9 {
tabManager.selectLastTab()
} else {
tabManager.selectTab(at: number - 1)
}
}
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
}
Divider()
Button("Jump to Latest Unread") {
AppDelegate.shared?.jumpToLatestUnread()
}
Button("Show Notifications") {
showNotificationsPopover()
}
}
}
}
private func showAboutPanel() {
AboutWindowController.shared.show()
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
switch mode {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
case .auto:
// Legacy value; treat like system and migrate.
NSApp.appearance = nil
appearanceMode = AppearanceMode.system.rawValue
}
}
private func updateSocketController() {
let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode)
if mode != .off {
TerminalController.shared.start(
tabManager: tabManager,
socketPath: SocketControlSettings.socketPath(),
accessMode: mode
)
} else {
TerminalController.shared.stop()
}
}
private var currentSocketMode: SocketControlMode {
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {
window.performClose(nil)
return
}
tabManager.closeCurrentPanelWithConfirmation()
}
private func showNotificationsPopover() {
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
static let shared = AboutWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.about")
window.center()
window.contentView = NSHostingView(rootView: AboutPanelView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SidebarDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Sidebar Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.sidebarDebug")
window.center()
window.contentView = NSHostingView(rootView: SidebarDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct AboutPanelView: View {
@Environment(\.openURL) private var openURL
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmuxterm")
private let docsURL = URL(string: "https://term.cmux.dev")
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
private var commit: String? {
if let value = Bundle.main.infoDictionary?["CMUXCommit"] as? String, !value.isEmpty {
return value
}
let env = ProcessInfo.processInfo.environment["CMUX_COMMIT"] ?? ""
return env.isEmpty ? nil : env
}
private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String }
var body: some View {
VStack(alignment: .center) {
Image(nsImage: NSApplication.shared.applicationIconImage)
.resizable()
.renderingMode(.original)
.frame(width: 96, height: 96)
.shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3)
VStack(alignment: .center, spacing: 32) {
VStack(alignment: .center, spacing: 8) {
Text("cmuxterm")
.bold()
.font(.title)
Text("A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.font(.caption)
.tint(.secondary)
.opacity(0.8)
}
.textSelection(.enabled)
VStack(spacing: 2) {
if let version {
AboutPropertyRow(label: "Version", text: version)
}
if let build {
AboutPropertyRow(label: "Build", text: build)
}
let commitText = commit ?? ""
let commitURL = commit.flatMap { hash in
URL(string: "https://github.com/manaflow-ai/cmuxterm/commit/\(hash)")
}
AboutPropertyRow(label: "Commit", text: commitText, url: commitURL)
}
.frame(maxWidth: .infinity)
HStack(spacing: 8) {
if let url = docsURL {
Button("Docs") {
openURL(url)
}
}
if let url = githubURL {
Button("GitHub") {
openURL(url)
}
}
}
if let copy = copyright, !copy.isEmpty {
Text(copy)
.font(.caption)
.textSelection(.enabled)
.tint(.secondary)
.opacity(0.8)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity)
}
.padding(.top, 8)
.padding(32)
.frame(minWidth: 280)
.background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
}
}
private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.54
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#101010"
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 0.79
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Sidebar Appearance")
.font(.headline)
GroupBox("Presets") {
Picker("Preset", selection: $sidebarPreset) {
ForEach(SidebarPresetOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.onChange(of: sidebarPreset) { _ in
applyPreset()
}
.padding(.top, 2)
}
GroupBox("Blur") {
VStack(alignment: .leading, spacing: 8) {
Picker("Material", selection: $sidebarMaterial) {
ForEach(SidebarMaterialOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("Blending", selection: $sidebarBlendMode) {
ForEach(SidebarBlendModeOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("State", selection: $sidebarState) {
ForEach(SidebarStateOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
HStack(spacing: 8) {
Text("Strength")
Slider(value: $sidebarBlurOpacity, in: 0...1)
Text(String(format: "%.0f%%", sidebarBlurOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $sidebarTintOpacity, in: 0...0.7)
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Shape") {
HStack(spacing: 8) {
Text("Corner Radius")
Slider(value: $sidebarCornerRadius, in: 0...20)
Text(String(format: "%.0f", sidebarCornerRadius))
.font(.caption)
.frame(width: 32, alignment: .trailing)
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset Tint") {
sidebarTintOpacity = 0.62
sidebarTintHex = "#000000"
}
Button("Reset Blur") {
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
sidebarState = SidebarStateOption.active.rawValue
sidebarBlurOpacity = 0.98
}
Button("Reset Shape") {
sidebarCornerRadius = 0.0
}
}
Button("Copy Config") {
copySidebarConfig()
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHex = nsColor.hexString()
}
)
}
private func copySidebarConfig() {
let payload = """
sidebarPreset=\(sidebarPreset)
sidebarMaterial=\(sidebarMaterial)
sidebarBlendMode=\(sidebarBlendMode)
sidebarState=\(sidebarState)
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
sidebarTintHex=\(sidebarTintHex)
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func applyPreset() {
guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return }
sidebarMaterial = preset.material.rawValue
sidebarBlendMode = preset.blendMode.rawValue
sidebarState = preset.state.rawValue
sidebarTintHex = preset.tintHex
sidebarTintOpacity = preset.tintOpacity
sidebarCornerRadius = preset.cornerRadius
sidebarBlurOpacity = preset.blurOpacity
}
}
// MARK: - Background Debug Window
private final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BackgroundDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 300),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Background Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.backgroundDebug")
window.center()
window.contentView = NSHostingView(rootView: BackgroundDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct BackgroundDebugView: View {
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
@AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow"
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Window Background Glass")
.font(.headline)
GroupBox("Glass Effect") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Enable Glass Effect", isOn: $bgGlassEnabled)
Picker("Material", selection: $bgGlassMaterial) {
Text("HUD Window").tag("hudWindow")
Text("Under Window").tag("underWindowBackground")
Text("Sidebar").tag("sidebar")
Text("Menu").tag("menu")
Text("Popover").tag("popover")
}
.disabled(!bgGlassEnabled)
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
.disabled(!bgGlassEnabled)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $bgGlassTintOpacity, in: 0...0.8)
.disabled(!bgGlassEnabled)
Text(String(format: "%.0f%%", bgGlassTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset") {
bgGlassTintHex = "#000000"
bgGlassTintOpacity = 0.05
bgGlassMaterial = "hudWindow"
bgGlassEnabled = true
updateWindowGlassTint()
}
Button("Copy Config") {
copyBgConfig()
}
}
Text("Tint changes apply live. Enable/disable requires reload.")
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }
.onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() }
}
private func updateWindowGlassTint() {
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) else { return }
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.updateTint(to: window, color: tintColor)
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
bgGlassTintHex = nsColor.hexString()
}
)
}
private func copyBgConfig() {
let payload = """
bgGlassEnabled=\(bgGlassEnabled)
bgGlassMaterial=\(bgGlassMaterial)
bgGlassTintHex=\(bgGlassTintHex)
bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity))
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private struct AboutPropertyRow: View {
private let label: String
private let text: String
private let url: URL?
init(label: String, text: String, url: URL? = nil) {
self.label = label
self.text = text
self.url = url
}
@ViewBuilder private var textView: some View {
Text(text)
.frame(width: 140, alignment: .leading)
.padding(.leading, 2)
.tint(.secondary)
.opacity(0.8)
.monospaced()
}
var body: some View {
HStack(spacing: 4) {
Text(label)
.frame(width: 126, alignment: .trailing)
.padding(.trailing, 2)
if let url {
Link(destination: url) {
textView
}
} else {
textView
}
}
.font(.callout)
.textSelection(.enabled)
.frame(maxWidth: .infinity)
}
}
private struct AboutVisualEffectBackground: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
let isEmphasized: Bool
init(
material: NSVisualEffectView.Material,
blendingMode: NSVisualEffectView.BlendingMode = .behindWindow,
isEmphasized: Bool = false
) {
self.material = material
self.blendingMode = blendingMode
self.isEmphasized = isEmphasized
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
nsView.material = material
nsView.blendingMode = blendingMode
nsView.isEmphasized = isEmphasized
}
func makeNSView(context: Context) -> NSVisualEffectView {
let visualEffect = NSVisualEffectView()
visualEffect.autoresizingMask = [.width, .height]
return visualEffect
}
}
enum AppearanceMode: String, CaseIterable, Identifiable {
case system
case light
case dark
case auto
var id: String { rawValue }
static var visibleCases: [AppearanceMode] {
[.system, .light, .dark]
}
var displayName: String {
switch self {
case .system:
return "System"
case .light:
return "Light"
case .dark:
return "Dark"
case .auto:
return "Auto"
}
}
}
struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShellIntegration") private var sidebarShellIntegration = true
@AppStorage("sidebarMaxLogEntries") private var sidebarMaxLogEntries = 50
@State private var notificationsShortcut = KeyboardShortcutSettings.showNotificationsShortcut()
@State private var jumpToUnreadShortcut = KeyboardShortcutSettings.jumpToUnreadShortcut()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Theme")
.font(.headline)
Picker("", selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
Divider()
Text("Sidebar")
.font(.headline)
Toggle("Show status", isOn: $sidebarShowStatusPills)
Toggle("Show git branch", isOn: $sidebarShowGitBranch)
Toggle("Show branch icon", isOn: $sidebarShowGitBranchIcon)
.disabled(!sidebarShowGitBranch)
Toggle("Show listening ports", isOn: $sidebarShowPorts)
Toggle("Show latest log entry", isOn: $sidebarShowLog)
Toggle("Show progress bar", isOn: $sidebarShowProgress)
Toggle("Shell integration (auto-detect git branch and ports)", isOn: $sidebarShellIntegration)
Stepper("Max log entries: \(sidebarMaxLogEntries)", value: $sidebarMaxLogEntries, in: 10...500, step: 10)
Text("Shell integration injects a precmd hook to report git branch and ports automatically.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Keyboard Shortcuts")
.font(.headline)
KeyboardShortcutRecorder(label: "Show Notifications", shortcut: $notificationsShortcut)
.onChange(of: notificationsShortcut) { newValue in
KeyboardShortcutSettings.setShowNotificationsShortcut(newValue)
}
KeyboardShortcutRecorder(label: "Jump to Unread", shortcut: $jumpToUnreadShortcut)
.onChange(of: jumpToUnreadShortcut) { newValue in
KeyboardShortcutSettings.setJumpToUnreadShortcut(newValue)
}
Text("Click to record a new shortcut.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Automation")
.font(.headline)
Picker("", selection: $socketControlMode) {
ForEach(SocketControlMode.allCases) { mode in
VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName)
Text(mode.description)
.font(.caption)
.foregroundColor(.secondary)
}
.tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
.accessibilityIdentifier("AutomationSocketModePicker")
Text("Expose a local Unix socket for programmatic control. This can be a security risk on shared machines.")
.font(.caption)
.foregroundColor(.secondary)
Text("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
HStack {
Spacer()
Button("Reset All Settings") {
resetAllSettings()
}
Spacer()
}
.padding(.top, 8)
}
.padding(20)
.padding(.top, 4)
}
.frame(minWidth: 420, minHeight: 360)
}
private func resetAllSettings() {
appearanceMode = AppearanceMode.dark.rawValue
socketControlMode = SocketControlSettings.defaultMode.rawValue
sidebarShowStatusPills = true
sidebarShowGitBranch = true
sidebarShowGitBranchIcon = false
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true
sidebarShellIntegration = true
sidebarMaxLogEntries = 50
KeyboardShortcutSettings.resetAll()
notificationsShortcut = KeyboardShortcutSettings.showNotificationsDefault
jumpToUnreadShortcut = KeyboardShortcutSettings.jumpToUnreadDefault
}
}
private struct SettingsRootView: View {
var body: some View {
SettingsView()
.background(WindowAccessor { window in
configureSettingsWindow(window)
})
}
private func configureSettingsWindow(_ window: NSWindow) {
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = false
window.styleMask.remove(.fullSizeContentView)
window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 420, height: 360)
if window.toolbar == nil {
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.settings.toolbar"))
toolbar.displayMode = .iconOnly
toolbar.sizeMode = .regular
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
toolbar.showsBaselineSeparator = false
window.toolbar = toolbar
window.toolbarStyle = .unified
}
let accessories = window.titlebarAccessoryViewControllers
for index in accessories.indices.reversed() {
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
guard identifier.hasPrefix("cmux.") else { continue }
window.removeTitlebarAccessoryViewController(at: index)
}
AppDelegate.shared?.applyWindowDecorations(to: window)
}
}