* Rename cmuxterm to cmux across entire codebase - Rename GitHub repos: manaflow-ai/cmuxterm -> manaflow-ai/cmux, manaflow-ai/homebrew-cmuxterm -> manaflow-ai/homebrew-cmux - Rename bundle IDs: com.cmuxterm.app -> com.cmux.app - Rename CLI: CLI/cmuxterm.swift -> CLI/cmux.swift - Rename homebrew submodule: homebrew-cmuxterm -> homebrew-cmux - Update all socket paths: /tmp/cmuxterm*.sock -> /tmp/cmux*.sock - Update all GitHub URLs, DMG names, Sparkle URLs - Update all source files, scripts, tests, docs, CI workflows * Bump version to 1.23.0
1096 lines
40 KiB
Swift
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 cmux") {
|
|
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/cmux")
|
|
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("cmux")
|
|
.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/cmux/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)
|
|
}
|
|
}
|