* Fix blank terminal after split operations and add visual tests ## Blank Terminal Fix - Add `needsRefreshAfterWindowChange` flag in GhosttyTerminalView - Force terminal refresh when view is added to window, even if size unchanged - Add `ghostty_surface_refresh()` call in attachToView for same-view reattachment - Add debug logging for surface attachment lifecycle (DEBUG builds only) ## Bonsplit Migration - Add bonsplit as local Swift package (vendor/bonsplit submodule) - Replace custom SplitTree with BonsplitController - Add Panel protocol with TerminalPanel and BrowserPanel implementations - Add SidebarTab as main tab container with BonsplitController - Remove old Splits/ directory (SplitTree, SplitView, TerminalSplitTreeView) ## Visual Screenshot Tests - Add test_visual_screenshots.py for automated visual regression testing - Uses in-app screenshot API (CGWindowListCreateImage) - no screen recording needed - Generates HTML report with before/after comparisons - Tests: splits, browser panels, focus switching, close operations, rapid cycles - Includes annotation fields for easy feedback ## Browser Shortcut (⌘⇧B) - Add keyboard shortcut to open browser panel in current pane - Add openBrowser() method to TabManager - Add shortcut configuration in KeyboardShortcutSettings ## Screenshot Command - Add 'screenshot' command to TerminalController for in-app window capture - Returns OK with screenshot ID and path ## Other - Add tests/visual_output/ and tests/visual_report.html to .gitignore * Add browser title subscription and set tab height to 30px - Subscribe to BrowserPanel.$pageTitle changes to update bonsplit tabs - Update tab titles in real-time as page navigation occurs - Clean up subscriptions when panels are removed - Set bonsplit tab bar and tab height to 30px (in submodule) * Fix socket API regressions in list_surfaces, list_bonsplit_tabs, focus_pane - list_surfaces: Remove [terminal]/[browser] suffix to keep UUID-only format that clients and tests expect for parsing - list_bonsplit_tabs --pane: Properly look up pane by UUID instead of creating a new PaneID (requires bonsplit PaneID.id to be public) - focus_pane: Accept both UUID strings and integer indices as documented * Fix browser panel stability and keyboard shortcuts - Prevent WKWebView focus lifecycle crashes during split/view reshuffles - Match bracket shortcuts via keyCode (Cmd+Shift+[ / ], Cmd+Ctrl+[ / ]) - Support Ghostty config goto_split:* keybinds when WebView is focused - Add focus_webview/is_webview_focused socket commands and regression tests - Rename SidebarTab to Workspace and update docs * Make ctrl+enter keybind test skippable Skip when the Ghostty keybind isn't configured or when osascript can't send keystrokes (no Accessibility permission), so VM runs stay green. * Auto-focus browser omnibar when blank When a browser surface is focused but no URL is loaded yet, focus the address bar instead of the WKWebView. * Stabilize socket surface indexing * Focus browser omnibar escape; add webview keybind UI tests - Escape in omnibar now returns focus to WKWebView\n- Add UI tests for Cmd+Ctrl+H pane navigation with WebKit focused (including Ghostty config)\n- Avoid flaky element screenshots in UpdatePillUITests on the UTM VM * Fix browser drag-to-split blanks and socket parsing * Fix webview-focused shortcuts and stabilize browser splits - Match ctrl/shift shortcuts by keyCode where needed (Ctrl+H, bracket keys) - Load Ghostty goto_split triggers reliably and refresh on config load - Add debug socket helpers: set_shortcut + simulate_shortcut for tests - Convert browser goto_split/keybind tests to socket-based injection (no osascript) - Bump bonsplit for drag-to-split fixes * Fix split layout collapse and harden socket pane APIs * Stabilize OSC 99 notification test timing * Fix terminal focus routing after split reparent * Support simulate_shortcut enter for focus routing test * Stabilize terminal focus routing test * Fix frozen new terminal tabs after many splits * Fix frozen new terminal tabs after splits * Fix terminal freeze on launch/new tabs * Update ghostty submodule * Fix terminal focus/render stalls after split churn * Fix nested split collapsing existing pane * Fix nested split collapse + stabilize new-surface focus * Update bonsplit submodule * Fix SIGINT test flake * Remove bonsplit tab-switch crossfade * Remove PROJECTS.md * Remove bonsplit tab selection animation * Ignore generated test reports * Middle click closes tab * Revert unintended .gitignore change * Fix build after main merge * Revert "Fix build after main merge" This reverts commit 16bf9816d0856b5385d52f886aa5eb50f3c9d9a4. * Revert "Merge remote-tracking branch 'origin/main' into fix/blank-terminal-and-visual-tests" This reverts commit 7c20fb53fd71fea7a19a3673f2dd73e5f0c783c4, reversing changes made to 0aff107d787bc9d8bbc28220090b4ca7af72e040. * Remove tab close fade animation * Use terminal.fill icon * Make terminal tab icon smaller * Match browser globe tab icon size * Bonsplit: tab min width 48 and tighter close button * Bonsplit: smaller tab title font * Show unread notification badge in bonsplit tabs and improve UI polish Sync unread notification state to bonsplit tab badges (blue dot). Improve EmptyPanelView with Terminal/Browser buttons and shortcut hints. Add tooltips to close tab button and search overlay buttons. * Fix reload.sh single-instance safety check on macOS Replace GNU-only `ps -o etimes=` with portable `ps -o etime=` and parse the dd-hh:mm:ss format manually for macOS compatibility. * Centralize keyboard shortcut definitions into Action enum Replace per-shortcut boilerplate with a single Action enum that holds the label, defaults key, and default binding for each shortcut. All call sites now use shortcut(for:). Settings UI is data-driven via ForEach(Action.allCases). Titlebar tooltips update dynamically when shortcuts are changed. Remove duplicate .keyboardShortcut() modifiers from menu items that are already handled by the event monitor. * Fix WKWebView consuming app menu shortcuts and close panel confirmation Add CmuxWebView subclass that routes key equivalents through the main menu before WebKit, so Cmd+N/Cmd+W/tab switching work when a browser pane is focused. Fix Cmd+W close-panel path: bypass Bonsplit delegate gating after the user confirms the running-process dialog by tracking forceCloseTabIds. Add unit tests (CmuxWebViewKeyEquivalentTests) and UI test scaffolding (MenuKeyEquivalentRoutingUITests) with a new cmux-unit Xcode scheme. * Update CLAUDE.md and PROJECTS.md with recent changes CLAUDE.md: enforce --tag for reload commands, add cleanup safety rules. PROJECTS.md: log notification badge, reload.sh fix, Cmd+W fix, WebView key equiv fix, and centralized shortcuts work. * Keep selection index stable on close * Add concepts page documenting terminology hierarchy New docs page explaining Window > Workspace > Pane > Surface > Panel hierarchy with aligned ASCII diagram. Updated tabs.mdx and splits.mdx to use consistent terminology (workspace instead of tab, surface instead of panel) and corrected outdated CLI command references. * Update bonsplit submodule * WIP: improve split close stability and UI regressions * Close terminal panel on child exit; hide terminal dirty dot * Fix split close/focus regressions and stabilize UI tests * Add unread Dock/Cmd+Tab badge with settings toggle * Fix browser-surface shortcuts and Cmd+L browser opening * Snapshot current workspace state before regression fixes * Update bonsplit submodule snapshot * Stabilize split-close regression capture and sidebar resize assertions * Change default Show Notifications shortcut from Cmd+Shift+I to Cmd+I * Fix update check readiness race, enable release update logging, and improve checking spinner * Restore terminal file drop, fix browser omnibar click focus, and add panel workspace ID mutation for surface moves * Add Cmd+digit workspace hints, titlebar shortcut pills, sidebar drag-reorder, and workspace placement settings * Add v2 browser automation API, surface move/reorder commands, and short-handle ref system to TerminalController * Add CLI browser command surface, --id-format flag, and move/reorder commands * Extend test clients with move/reorder APIs, ref-handle support, and increased timeouts * Harden test runner scripts with deterministic builds, retry logic, and robust socket readiness * Stabilize existing test suites with focus-wait helpers, increased timeouts, and API shape updates * Add terminal file drop e2e regression test * Add v2 browser API, CLI ref resolution, and surface move/reorder test suites * Add unit tests for shortcut hints, workspace reorder, drop planner, and update UI test stabilization * Add cmux-debug-windows skill with snapshot script and agent config * Update project docs: mark browser parity and move/reorder phases complete, add parallel agent workflow guidelines * Update bonsplit submodule: re-entrant setPosition guard, tab shortcut hints, and moveTab/reorderTab API * Add browser agent UX improvements: snapshot refs, placement reuse, diagnostics, and skill docs - Upgrade browser.snapshot to emit accessibility tree text with element refs (eN) - Add right-sibling pane reuse policy for browser.open_split placement - Add rich not_found diagnostics with retry logic for selector actions - Support --snapshot-after for post-action verification on mutating commands - Allow browser fill with empty text for clearing inputs - Default CLI --id-format to refs-first (UUIDs opt-in via --id-format uuids|both) - Format legacy new-pane/new-surface output with short surface refs - Add skills/cmuxterm-browser/ and skills/cmuxterm/ end-user skill docs - Add regression tests for placement policy, snapshot refs, diagnostics, and ID defaults * Update bonsplit submodule: keep raster favicons in color when inactive
1838 lines
76 KiB
Swift
1838 lines
76 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()
|
|
@StateObject private var sidebarSelectionState = SidebarSelectionState()
|
|
private let primaryWindowId = UUID()
|
|
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
|
|
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
|
|
@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)
|
|
}
|
|
migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults)
|
|
|
|
// UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance
|
|
// callbacks (e.g. `.onAppear`) are delayed or skipped.
|
|
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
|
}
|
|
|
|
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
|
|
|
|
appendEnvPathIfMissing(
|
|
"XDG_DATA_DIRS",
|
|
path: dataDir,
|
|
defaultValue: "/usr/local/share:/usr/share"
|
|
)
|
|
appendEnvPathIfMissing("MANPATH", path: manDir)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func migrateSidebarAppearanceDefaultsIfNeeded(defaults: UserDefaults) {
|
|
let migrationKey = "sidebarAppearanceDefaultsVersion"
|
|
let targetVersion = 1
|
|
guard defaults.integer(forKey: migrationKey) < targetVersion else { return }
|
|
|
|
func normalizeHex(_ value: String) -> String {
|
|
value
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "#", with: "")
|
|
.uppercased()
|
|
}
|
|
|
|
func approximatelyEqual(_ lhs: Double, _ rhs: Double, tolerance: Double = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= tolerance
|
|
}
|
|
|
|
let material = defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue
|
|
let blendMode = defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.behindWindow.rawValue
|
|
let state = defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue
|
|
let tintHex = defaults.string(forKey: "sidebarTintHex") ?? "#101010"
|
|
let tintOpacity = defaults.object(forKey: "sidebarTintOpacity") as? Double ?? 0.54
|
|
let blurOpacity = defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 0.79
|
|
let cornerRadius = defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0
|
|
|
|
let usesLegacyDefaults =
|
|
material == SidebarMaterialOption.sidebar.rawValue &&
|
|
blendMode == SidebarBlendModeOption.behindWindow.rawValue &&
|
|
state == SidebarStateOption.followWindow.rawValue &&
|
|
normalizeHex(tintHex) == "101010" &&
|
|
approximatelyEqual(tintOpacity, 0.54) &&
|
|
approximatelyEqual(blurOpacity, 0.79) &&
|
|
approximatelyEqual(cornerRadius, 0.0)
|
|
|
|
if usesLegacyDefaults {
|
|
let preset = SidebarPresetOption.nativeSidebar
|
|
defaults.set(preset.rawValue, forKey: "sidebarPreset")
|
|
defaults.set(preset.material.rawValue, forKey: "sidebarMaterial")
|
|
defaults.set(preset.blendMode.rawValue, forKey: "sidebarBlendMode")
|
|
defaults.set(preset.state.rawValue, forKey: "sidebarState")
|
|
defaults.set(preset.tintHex, forKey: "sidebarTintHex")
|
|
defaults.set(preset.tintOpacity, forKey: "sidebarTintOpacity")
|
|
defaults.set(preset.blurOpacity, forKey: "sidebarBlurOpacity")
|
|
defaults.set(preset.cornerRadius, forKey: "sidebarCornerRadius")
|
|
}
|
|
|
|
defaults.set(targetVersion, forKey: migrationKey)
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId)
|
|
.environmentObject(tabManager)
|
|
.environmentObject(notificationStore)
|
|
.environmentObject(sidebarState)
|
|
.environmentObject(sidebarSelectionState)
|
|
.onAppear {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {
|
|
UpdateLogStore.shared.append("ui test: cmuxApp onAppear")
|
|
}
|
|
#endif
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
CommandMenu("Notifications") {
|
|
let snapshot = notificationMenuSnapshot
|
|
|
|
Button(snapshot.stateHintTitle) {}
|
|
.disabled(true)
|
|
|
|
if !snapshot.recentNotifications.isEmpty {
|
|
Divider()
|
|
|
|
ForEach(snapshot.recentNotifications) { notification in
|
|
Button(notificationMenuItemTitle(for: notification)) {
|
|
openNotificationFromMainMenu(notification)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
}
|
|
|
|
Button("Show Notifications") {
|
|
showNotificationsPopover()
|
|
}
|
|
|
|
Button("Jump to Latest Unread") {
|
|
appDelegate.jumpToLatestUnread()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button("Mark All Read") {
|
|
notificationStore.markAllRead()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button("Clear All") {
|
|
notificationStore.clearAll()
|
|
}
|
|
.disabled(!snapshot.hasNotifications)
|
|
}
|
|
|
|
#if DEBUG
|
|
CommandMenu("Debug") {
|
|
Button("New Tab With Lorem Search Text") {
|
|
appDelegate.openDebugLoremTab(nil)
|
|
}
|
|
|
|
Button("New Tab With Large Scrollback") {
|
|
appDelegate.openDebugScrollbackTab(nil)
|
|
}
|
|
|
|
Divider()
|
|
Menu("Debug Windows") {
|
|
Button("Debug Window Controls…") {
|
|
DebugWindowControlsWindowController.shared.show()
|
|
}
|
|
|
|
Divider()
|
|
Button("Sidebar Debug…") {
|
|
SidebarDebugWindowController.shared.show()
|
|
}
|
|
|
|
Button("Background Debug…") {
|
|
BackgroundDebugWindowController.shared.show()
|
|
}
|
|
|
|
Button("Menu Bar Extra Debug…") {
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Open All Debug Windows") {
|
|
openAllDebugWindows()
|
|
}
|
|
}
|
|
|
|
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
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 Window") {
|
|
appDelegate.openNewMainWindow(nil)
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
|
|
|
Button("New Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
|
|
}
|
|
}
|
|
|
|
// Close tab/workspace
|
|
CommandGroup(after: .newItem) {
|
|
// Terminal semantics:
|
|
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
|
// tab in the last workspace, it closes the window.
|
|
Button("Close Tab") {
|
|
closePanelOrWindow()
|
|
}
|
|
.keyboardShortcut("w", modifiers: .command)
|
|
|
|
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
|
|
// is the last workspace, it closes the window.
|
|
Button("Close Workspace") {
|
|
closeTabOrWindow()
|
|
}
|
|
.keyboardShortcut("w", modifiers: [.command, .shift])
|
|
}
|
|
|
|
// Find
|
|
CommandGroup(after: .textEditing) {
|
|
Menu("Find") {
|
|
Button("Find…") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
|
|
}
|
|
.keyboardShortcut("f", modifiers: .command)
|
|
|
|
Button("Find Next") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
|
|
}
|
|
.keyboardShortcut("g", modifiers: .command)
|
|
|
|
Button("Find Previous") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
|
|
}
|
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
|
|
Divider()
|
|
|
|
Button("Hide Find Bar") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
|
|
}
|
|
.keyboardShortcut("f", modifiers: [.command, .shift])
|
|
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
|
|
|
|
Divider()
|
|
|
|
Button("Use Selection for Find") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
|
|
}
|
|
.keyboardShortcut("e", modifiers: .command)
|
|
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
|
|
}
|
|
}
|
|
|
|
// Tab navigation
|
|
CommandGroup(after: .toolbar) {
|
|
Button("Toggle Sidebar") {
|
|
sidebarState.toggle()
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Next Surface") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
|
|
}
|
|
|
|
Button("Previous Surface") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
|
|
}
|
|
|
|
Button("Back") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).navigateBack()
|
|
}
|
|
.keyboardShortcut("[", modifiers: .command)
|
|
|
|
Button("Forward") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).navigateForward()
|
|
}
|
|
.keyboardShortcut("]", modifiers: .command)
|
|
|
|
Button("Reload Page") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
|
|
}
|
|
.keyboardShortcut("r", modifiers: .command)
|
|
|
|
Button("Next Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
|
|
}
|
|
|
|
Button("Previous Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
|
|
}
|
|
|
|
Divider()
|
|
|
|
splitCommandButton(title: "Split Right", shortcut: splitRightMenuShortcut) {
|
|
performSplitFromMenu(direction: .right)
|
|
}
|
|
|
|
splitCommandButton(title: "Split Down", shortcut: splitDownMenuShortcut) {
|
|
performSplitFromMenu(direction: .down)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
|
ForEach(1...9, id: \.self) { number in
|
|
Button("Tab \(number)") {
|
|
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
|
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
|
|
manager.selectTab(at: targetIndex)
|
|
}
|
|
}
|
|
.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 var splitRightMenuShortcut: StoredShortcut {
|
|
decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut)
|
|
}
|
|
|
|
private var splitDownMenuShortcut: StoredShortcut {
|
|
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
|
|
}
|
|
|
|
private var notificationMenuSnapshot: NotificationMenuSnapshot {
|
|
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
|
}
|
|
|
|
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
|
guard !data.isEmpty,
|
|
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
|
return fallback
|
|
}
|
|
return shortcut
|
|
}
|
|
|
|
private func notificationMenuItemTitle(for notification: TerminalNotification) -> String {
|
|
let tabTitle = appDelegate.tabTitle(for: notification.tabId)
|
|
return MenuBarNotificationLineFormatter.menuTitle(notification: notification, tabTitle: tabTitle)
|
|
}
|
|
|
|
private func openNotificationFromMainMenu(_ notification: TerminalNotification) {
|
|
_ = appDelegate.openNotification(
|
|
tabId: notification.tabId,
|
|
surfaceId: notification.surfaceId,
|
|
notificationId: notification.id
|
|
)
|
|
}
|
|
|
|
private func performSplitFromMenu(direction: SplitDirection) {
|
|
if AppDelegate.shared?.performSplitShortcut(direction: direction) == true {
|
|
return
|
|
}
|
|
tabManager.createSplit(direction: direction)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
|
|
if let key = keyEquivalent(for: shortcut) {
|
|
Button(title, action: action)
|
|
.keyboardShortcut(key, modifiers: eventModifiers(for: shortcut))
|
|
} else {
|
|
Button(title, action: action)
|
|
}
|
|
}
|
|
|
|
private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? {
|
|
switch shortcut.key {
|
|
case "←":
|
|
return .leftArrow
|
|
case "→":
|
|
return .rightArrow
|
|
case "↑":
|
|
return .upArrow
|
|
case "↓":
|
|
return .downArrow
|
|
case "\t":
|
|
return .tab
|
|
default:
|
|
let lowered = shortcut.key.lowercased()
|
|
guard lowered.count == 1, let character = lowered.first else { return nil }
|
|
return KeyEquivalent(character)
|
|
}
|
|
}
|
|
|
|
private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers {
|
|
var modifiers: EventModifiers = []
|
|
if shortcut.command {
|
|
modifiers.insert(.command)
|
|
}
|
|
if shortcut.shift {
|
|
modifiers.insert(.shift)
|
|
}
|
|
if shortcut.option {
|
|
modifiers.insert(.option)
|
|
}
|
|
if shortcut.control {
|
|
modifiers.insert(.control)
|
|
}
|
|
return modifiers
|
|
}
|
|
|
|
private func closePanelOrWindow() {
|
|
if let window = NSApp.keyWindow,
|
|
window.identifier?.rawValue == "cmux.settings" {
|
|
window.performClose(nil)
|
|
return
|
|
}
|
|
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
|
|
}
|
|
|
|
private func closeTabOrWindow() {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
|
|
}
|
|
|
|
private func showNotificationsPopover() {
|
|
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
|
|
}
|
|
|
|
private func openAllDebugWindows() {
|
|
SidebarDebugWindowController.shared.show()
|
|
BackgroundDebugWindowController.shared.show()
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
}
|
|
|
|
private enum DebugWindowConfigSnapshot {
|
|
static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(combinedPayload(defaults: defaults), forType: .string)
|
|
}
|
|
|
|
static func combinedPayload(defaults: UserDefaults = .standard) -> String {
|
|
let sidebarPayload = """
|
|
sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue))
|
|
sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue))
|
|
sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue))
|
|
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
|
|
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
|
|
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
|
|
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
|
|
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintYKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintY)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY)))
|
|
shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints))
|
|
"""
|
|
|
|
let backgroundPayload = """
|
|
bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true))
|
|
bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow"))
|
|
bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000"))
|
|
bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.05)))
|
|
"""
|
|
|
|
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
|
|
|
|
return """
|
|
# Sidebar Debug
|
|
\(sidebarPayload)
|
|
|
|
# Background Debug
|
|
\(backgroundPayload)
|
|
|
|
# Menu Bar Extra Debug
|
|
\(menuBarPayload)
|
|
"""
|
|
}
|
|
|
|
private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String {
|
|
defaults.string(forKey: key) ?? fallback
|
|
}
|
|
|
|
private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double {
|
|
if let value = defaults.object(forKey: key) as? NSNumber {
|
|
return value.doubleValue
|
|
}
|
|
if let text = defaults.string(forKey: key), let parsed = Double(text) {
|
|
return parsed
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool {
|
|
guard defaults.object(forKey: key) != nil else { return fallback }
|
|
return defaults.bool(forKey: key)
|
|
}
|
|
}
|
|
|
|
private final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = DebugWindowControlsWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 560),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Debug Window Controls"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.debugWindowControls")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: DebugWindowControlsView())
|
|
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 DebugWindowControlsView: View {
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Debug Window Controls")
|
|
.font(.headline)
|
|
|
|
GroupBox("Open") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button("Sidebar Debug…") {
|
|
SidebarDebugWindowController.shared.show()
|
|
}
|
|
Button("Background Debug…") {
|
|
BackgroundDebugWindowController.shared.show()
|
|
}
|
|
Button("Menu Bar Extra Debug…") {
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
Button("Open All Debug Windows") {
|
|
SidebarDebugWindowController.shared.show()
|
|
BackgroundDebugWindowController.shared.show()
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Shortcut Hints") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
hintOffsetSection(
|
|
"Sidebar Cmd+1…9",
|
|
x: $sidebarShortcutHintXOffset,
|
|
y: $sidebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Titlebar Buttons",
|
|
x: $titlebarShortcutHintXOffset,
|
|
y: $titlebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Pane Ctrl/Cmd+1…9",
|
|
x: $paneShortcutHintXOffset,
|
|
y: $paneShortcutHintYOffset
|
|
)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Reset Hints") {
|
|
resetShortcutHintOffsets()
|
|
}
|
|
Button("Copy Hint Config") {
|
|
copyShortcutHintConfig()
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Copy") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button("Copy All Debug Config") {
|
|
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
|
|
}
|
|
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private func hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
sliderRow("X", value: x)
|
|
sliderRow("Y", value: y)
|
|
}
|
|
}
|
|
|
|
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
|
|
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func resetShortcutHintOffsets() {
|
|
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
}
|
|
|
|
private func copyShortcutHintConfig() {
|
|
let payload = """
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
|
|
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
|
|
"""
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
}
|
|
|
|
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.18
|
|
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
|
|
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
|
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
|
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
|
|
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
|
|
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)
|
|
}
|
|
|
|
GroupBox("Shortcut Hints") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
hintOffsetSection(
|
|
"Sidebar Cmd+1…9",
|
|
x: $sidebarShortcutHintXOffset,
|
|
y: $sidebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Titlebar Buttons",
|
|
x: $titlebarShortcutHintXOffset,
|
|
y: $titlebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Pane Ctrl/Cmd+1…9",
|
|
x: $paneShortcutHintXOffset,
|
|
y: $paneShortcutHintYOffset
|
|
)
|
|
}
|
|
.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("Reset Hints") {
|
|
resetShortcutHintOffsets()
|
|
}
|
|
}
|
|
|
|
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 hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
sliderRow("X", value: x)
|
|
sliderRow("Y", value: y)
|
|
}
|
|
}
|
|
|
|
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
|
|
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func resetShortcutHintOffsets() {
|
|
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
}
|
|
|
|
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))
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
|
|
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
|
|
"""
|
|
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: - Menu Bar Extra Debug Window
|
|
|
|
private final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = MenuBarExtraDebugWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 430),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Menu Bar Extra Debug"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.menubarDebug")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: MenuBarExtraDebugView())
|
|
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 MenuBarExtraDebugView: View {
|
|
@AppStorage(MenuBarIconDebugSettings.previewEnabledKey) private var previewEnabled = false
|
|
@AppStorage(MenuBarIconDebugSettings.previewCountKey) private var previewCount = 1
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectXKey) private var badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectYKey) private var badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectWidthKey) private var badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectHeightKey) private var badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitFontSizeKey) private var singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitFontSizeKey) private var multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitYOffsetKey) private var singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitYOffsetKey) private var multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitXAdjustKey) private var singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitXAdjustKey) private var multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
|
|
@AppStorage(MenuBarIconDebugSettings.textRectWidthAdjustKey) private var textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Menu Bar Extra Icon")
|
|
.font(.headline)
|
|
|
|
GroupBox("Preview Count") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle("Override unread count", isOn: $previewEnabled)
|
|
|
|
Stepper(value: $previewCount, in: 0...99) {
|
|
HStack {
|
|
Text("Unread Count")
|
|
Spacer()
|
|
Text("\(previewCount)")
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.disabled(!previewEnabled)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Badge Rect") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f")
|
|
sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f")
|
|
sliderRow("Width", value: $badgeRectWidth, range: 4...14, format: "%.2f")
|
|
sliderRow("Height", value: $badgeRectHeight, range: 4...14, format: "%.2f")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Badge Text") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f")
|
|
sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f")
|
|
sliderRow("1-digit X", value: $singleDigitXAdjust, range: -4...4, format: "%.2f")
|
|
sliderRow("2-digit X", value: $multiDigitXAdjust, range: -4...4, format: "%.2f")
|
|
sliderRow("1-digit Y", value: $singleDigitYOffset, range: -3...4, format: "%.2f")
|
|
sliderRow("2-digit Y", value: $multiDigitYOffset, range: -3...4, format: "%.2f")
|
|
sliderRow("Text width adjust", value: $textRectWidthAdjust, range: -3...5, format: "%.2f")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Reset") {
|
|
previewEnabled = false
|
|
previewCount = 1
|
|
badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
|
|
badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
|
|
badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
|
|
badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
|
|
singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
|
|
multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
|
|
singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
|
|
multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
|
|
singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
|
|
multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
|
|
textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
|
|
applyLiveUpdate()
|
|
}
|
|
|
|
Button("Copy Config") {
|
|
let payload = MenuBarIconDebugSettings.copyPayload()
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
}
|
|
|
|
Text("Tip: enable override count, then tune until the menu bar icon looks right.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.onAppear { applyLiveUpdate() }
|
|
.onChange(of: previewEnabled) { _ in applyLiveUpdate() }
|
|
.onChange(of: previewCount) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectX) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectY) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectWidth) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectHeight) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() }
|
|
.onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() }
|
|
}
|
|
|
|
private func sliderRow(
|
|
_ label: String,
|
|
value: Binding<Double>,
|
|
range: ClosedRange<Double>,
|
|
format: String
|
|
) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: range)
|
|
Text(String(format: format, value.wrappedValue))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 58, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func applyLiveUpdate() {
|
|
AppDelegate.shared?.refreshMenuBarExtraForDebug()
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
let window: NSWindow? = {
|
|
if let key = NSApp.keyWindow,
|
|
let raw = key.identifier?.rawValue,
|
|
raw == "cmux.main" || raw.hasPrefix("cmux.main.") {
|
|
return key
|
|
}
|
|
return NSApp.windows.first(where: {
|
|
guard let raw = $0.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
})
|
|
}()
|
|
guard let window 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(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
@State private var shortcutResetToken = UUID()
|
|
|
|
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("Keyboard Shortcuts")
|
|
.font(.headline)
|
|
|
|
ForEach(KeyboardShortcutSettings.Action.allCases) { action in
|
|
ShortcutSettingRow(action: action)
|
|
}
|
|
.id(shortcutResetToken)
|
|
|
|
Text("Click to record a new shortcut.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Divider()
|
|
|
|
Text("Workspaces")
|
|
.font(.headline)
|
|
|
|
Picker("", selection: $newWorkspacePlacement) {
|
|
ForEach(NewWorkspacePlacement.allCases) { placement in
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(placement.displayName)
|
|
Text(placement.description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.tag(placement.rawValue)
|
|
}
|
|
}
|
|
.pickerStyle(.radioGroup)
|
|
.labelsHidden()
|
|
|
|
Text("Controls where new workspaces are inserted in the sidebar list.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Divider()
|
|
|
|
Text("Notifications")
|
|
.font(.headline)
|
|
|
|
Toggle("Show unread count on app icon (Dock and Cmd+Tab)", isOn: $notificationDockBadgeEnabled)
|
|
|
|
Text("Displays unread notification count as a red badge on the app icon.")
|
|
.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()
|
|
|
|
Text("Browser")
|
|
.font(.headline)
|
|
|
|
Picker("Default Search Engine", selection: $browserSearchEngine) {
|
|
ForEach(BrowserSearchEngine.allCases) { engine in
|
|
Text(engine.displayName).tag(engine.rawValue)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Toggle("Show Search Suggestions", isOn: $browserSearchSuggestionsEnabled)
|
|
|
|
Text("Used by the browser address bar when input is not a URL.")
|
|
.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
|
|
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
KeyboardShortcutSettings.resetAll()
|
|
shortcutResetToken = UUID()
|
|
}
|
|
}
|
|
|
|
private struct ShortcutSettingRow: View {
|
|
let action: KeyboardShortcutSettings.Action
|
|
@State private var shortcut: StoredShortcut
|
|
|
|
init(action: KeyboardShortcutSettings.Action) {
|
|
self.action = action
|
|
_shortcut = State(initialValue: KeyboardShortcutSettings.shortcut(for: action))
|
|
}
|
|
|
|
var body: some View {
|
|
KeyboardShortcutRecorder(label: action.label, shortcut: $shortcut)
|
|
.onChange(of: shortcut) { newValue in
|
|
KeyboardShortcutSettings.setShortcut(newValue, for: action)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
|
let latest = KeyboardShortcutSettings.shortcut(for: action)
|
|
if latest != shortcut {
|
|
shortcut = latest
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|