Merge origin/main into issue-180-http-nonsecure-hosts
This commit is contained in:
commit
c9cafc0806
19 changed files with 887 additions and 162 deletions
|
|
@ -365,11 +365,19 @@ final class SocketClient {
|
|||
}
|
||||
|
||||
let raw = try send(command: requestLine)
|
||||
|
||||
// The server may return plain-text errors (e.g., "ERROR: Access denied ...")
|
||||
// before the JSON protocol starts. Surface these directly instead of letting
|
||||
// JSONSerialization throw a confusing parse error.
|
||||
if raw.hasPrefix("ERROR:") {
|
||||
throw CLIError(message: raw)
|
||||
}
|
||||
|
||||
guard let responseData = raw.data(using: .utf8) else {
|
||||
throw CLIError(message: "Invalid UTF-8 v2 response")
|
||||
}
|
||||
guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else {
|
||||
throw CLIError(message: "Invalid v2 response")
|
||||
throw CLIError(message: "Invalid v2 response: \(raw)")
|
||||
}
|
||||
|
||||
if let ok = response["ok"] as? Bool, ok {
|
||||
|
|
|
|||
|
|
@ -88,9 +88,16 @@ _cmux_prompt_command() {
|
|||
|
||||
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
|
||||
# so update on every prompt (still async + de-duped by the running-job check).
|
||||
# When pwd changes (cd into a different repo), kill the old probe and start fresh
|
||||
# so the sidebar picks up the new branch immediately.
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
|
||||
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$_CMUX_GIT_JOB_PID" ]] || ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_GIT_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_LAST_RUN=$now
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,11 +139,12 @@ _cmux_preexec() {
|
|||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
|
||||
# Heuristic: git commands can change branch/dirty state without changing $PWD.
|
||||
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
if [[ "$cmd" == git\ * || "$cmd" == git ]]; then
|
||||
_CMUX_GIT_FORCE=1
|
||||
fi
|
||||
case "$cmd" in
|
||||
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
|
||||
_CMUX_GIT_FORCE=1 ;;
|
||||
esac
|
||||
|
||||
# Register TTY + kick batched port scan for foreground commands (servers).
|
||||
_cmux_report_tty_once
|
||||
|
|
@ -196,6 +197,9 @@ _cmux_precmd() {
|
|||
head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
|
||||
if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
|
||||
_CMUX_GIT_HEAD_MTIME="$head_mtime"
|
||||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
should_git=1
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private var workspaceObserver: NSObjectProtocol?
|
||||
private var windowKeyObserver: NSObjectProtocol?
|
||||
private var shortcutMonitor: Any?
|
||||
private var shortcutDefaultsObserver: NSObjectProtocol?
|
||||
private var ghosttyConfigObserver: NSObjectProtocol?
|
||||
private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
|
||||
private var ghosttyGotoSplitRightShortcut: StoredShortcut?
|
||||
|
|
@ -336,6 +337,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
installWindowKeyEquivalentSwizzle()
|
||||
installBrowserAddressBarFocusObservers()
|
||||
installShortcutMonitor()
|
||||
installShortcutDefaultsObserver()
|
||||
NSApp.servicesProvider = self
|
||||
#if DEBUG
|
||||
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
|
||||
|
|
@ -1460,6 +1462,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
private func installShortcutDefaultsObserver() {
|
||||
guard shortcutDefaultsObserver == nil else { return }
|
||||
shortcutDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.refreshSplitButtonTooltipsAcrossWorkspaces()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSplitButtonTooltipsAcrossWorkspaces() {
|
||||
var refreshedManagers: Set<ObjectIdentifier> = []
|
||||
if let manager = tabManager {
|
||||
manager.refreshSplitButtonTooltips()
|
||||
refreshedManagers.insert(ObjectIdentifier(manager))
|
||||
}
|
||||
for context in mainWindowContexts.values {
|
||||
let manager = context.tabManager
|
||||
let identifier = ObjectIdentifier(manager)
|
||||
guard refreshedManagers.insert(identifier).inserted else { continue }
|
||||
manager.refreshSplitButtonTooltips()
|
||||
}
|
||||
}
|
||||
|
||||
private func installGhosttyConfigObserver() {
|
||||
guard ghosttyConfigObserver == nil else { return }
|
||||
ghosttyConfigObserver = NotificationCenter.default.addObserver(
|
||||
|
|
@ -1861,6 +1888,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) {
|
||||
_ = performBrowserSplitShortcut(direction: .right)
|
||||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) {
|
||||
_ = performBrowserSplitShortcut(direction: .down)
|
||||
return true
|
||||
}
|
||||
|
||||
// Surface navigation (legacy Ctrl+Tab support)
|
||||
if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) {
|
||||
tabManager?.selectNextSurface()
|
||||
|
|
@ -2041,6 +2078,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
|
||||
_ = focusBrowserAddressBar(panelId: panelId)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts
|
||||
/// through the same app-level shortcut handler used by the local key monitor.
|
||||
@discardableResult
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ final class SidebarState: ObservableObject {
|
|||
final class FileDropOverlayView: NSView {
|
||||
/// Fallback handler when no terminal is found under the drop point.
|
||||
var onDrop: (([URL]) -> Bool)?
|
||||
private var isForwardingMouseEvent = false
|
||||
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
|
||||
|
|
@ -207,12 +208,19 @@ final class FileDropOverlayView: NSView {
|
|||
// window.sendEvent(), which caches the mouse target and causes infinite recursion.
|
||||
|
||||
private func forwardEvent(_ event: NSEvent) {
|
||||
guard !isForwardingMouseEvent else { return }
|
||||
guard let window, let contentView = window.contentView else { return }
|
||||
|
||||
isForwardingMouseEvent = true
|
||||
isHidden = true
|
||||
defer {
|
||||
isHidden = false
|
||||
isForwardingMouseEvent = false
|
||||
}
|
||||
|
||||
let point = contentView.convert(event.locationInWindow, from: nil)
|
||||
let target = contentView.hitTest(point)
|
||||
isHidden = false
|
||||
guard let target else { return }
|
||||
guard let target, target !== self else { return }
|
||||
|
||||
switch event.type {
|
||||
case .leftMouseDown: target.mouseDown(with: event)
|
||||
|
|
@ -273,9 +281,9 @@ final class FileDropOverlayView: NSView {
|
|||
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
let point = contentView.convert(windowPoint, from: nil)
|
||||
let hitView = contentView.hitTest(point)
|
||||
isHidden = false
|
||||
|
||||
var current: NSView? = hitView
|
||||
while let view = current {
|
||||
|
|
@ -411,6 +419,7 @@ struct ContentView: View {
|
|||
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||
@State private var titlebarThemeGeneration: UInt64 = 0
|
||||
@State private var sidebarDraggedTabId: UUID?
|
||||
|
||||
private var sidebarView: some View {
|
||||
VerticalTabsSidebar(
|
||||
|
|
@ -523,6 +532,13 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var terminalContentWithSidebarDropOverlay: some View {
|
||||
terminalContent
|
||||
.overlay {
|
||||
SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId)
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
||||
|
||||
// Background glass settings
|
||||
|
|
@ -651,7 +667,7 @@ struct ContentView: View {
|
|||
// Overlay mode: terminal extends full width, sidebar on top
|
||||
// This allows withinWindow blur to see the terminal content
|
||||
ZStack(alignment: .leading) {
|
||||
terminalContent
|
||||
terminalContentWithSidebarDropOverlay
|
||||
.padding(.leading, sidebarState.isVisible ? sidebarWidth : 0)
|
||||
if sidebarState.isVisible {
|
||||
sidebarView
|
||||
|
|
@ -663,7 +679,7 @@ struct ContentView: View {
|
|||
if sidebarState.isVisible {
|
||||
sidebarView
|
||||
}
|
||||
terminalContent
|
||||
terminalContentWithSidebarDropOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -772,6 +788,16 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in
|
||||
let tabId = SidebarDragLifecycleNotification.tabId(from: notification)
|
||||
sidebarDraggedTabId = tabId
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"sidebar.dragState.content tab=\(debugShortWorkspaceId(tabId)) " +
|
||||
"reason=\(SidebarDragLifecycleNotification.reason(from: notification))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
|
||||
sidebarMinX = frame.minX
|
||||
}
|
||||
|
|
@ -1031,6 +1057,7 @@ struct VerticalTabsSidebar: View {
|
|||
@Binding var lastSidebarSelectionIndex: Int?
|
||||
@StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor()
|
||||
@StateObject private var dragAutoScrollController = SidebarDragAutoScrollController()
|
||||
@StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor()
|
||||
@State private var draggedTabId: UUID?
|
||||
@State private var dropIndicator: SidebarDropIndicator?
|
||||
|
||||
|
|
@ -1114,18 +1141,53 @@ struct VerticalTabsSidebar: View {
|
|||
commandKeyMonitor.start()
|
||||
draggedTabId = nil
|
||||
dropIndicator = nil
|
||||
SidebarDragLifecycleNotification.postStateDidChange(
|
||||
tabId: nil,
|
||||
reason: "sidebar_appear"
|
||||
)
|
||||
}
|
||||
.onDisappear {
|
||||
commandKeyMonitor.stop()
|
||||
dragAutoScrollController.stop()
|
||||
dragFailsafeMonitor.stop()
|
||||
draggedTabId = nil
|
||||
dropIndicator = nil
|
||||
SidebarDragLifecycleNotification.postStateDidChange(
|
||||
tabId: nil,
|
||||
reason: "sidebar_disappear"
|
||||
)
|
||||
}
|
||||
.onChange(of: draggedTabId) { newDraggedTabId in
|
||||
guard newDraggedTabId == nil else { return }
|
||||
SidebarDragLifecycleNotification.postStateDidChange(
|
||||
tabId: newDraggedTabId,
|
||||
reason: "drag_state_change"
|
||||
)
|
||||
#if DEBUG
|
||||
dlog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))")
|
||||
#endif
|
||||
if newDraggedTabId != nil {
|
||||
dragFailsafeMonitor.start {
|
||||
SidebarDragLifecycleNotification.postClearRequest(reason: $0)
|
||||
}
|
||||
return
|
||||
}
|
||||
dragFailsafeMonitor.stop()
|
||||
dragAutoScrollController.stop()
|
||||
dropIndicator = nil
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in
|
||||
guard draggedTabId != nil else { return }
|
||||
let reason = SidebarDragLifecycleNotification.reason(from: notification)
|
||||
#if DEBUG
|
||||
dlog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)")
|
||||
#endif
|
||||
draggedTabId = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func debugShortSidebarTabId(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(5))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1161,6 +1223,207 @@ enum ShortcutHintDebugSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum SidebarDragLifecycleNotification {
|
||||
static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange")
|
||||
static let requestClear = Notification.Name("cmux.sidebarDragRequestClear")
|
||||
static let tabIdKey = "tabId"
|
||||
static let reasonKey = "reason"
|
||||
|
||||
static func postStateDidChange(tabId: UUID?, reason: String) {
|
||||
var userInfo: [AnyHashable: Any] = [reasonKey: reason]
|
||||
if let tabId {
|
||||
userInfo[tabIdKey] = tabId
|
||||
}
|
||||
NotificationCenter.default.post(
|
||||
name: stateDidChange,
|
||||
object: nil,
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
|
||||
static func postClearRequest(reason: String) {
|
||||
NotificationCenter.default.post(
|
||||
name: requestClear,
|
||||
object: nil,
|
||||
userInfo: [reasonKey: reason]
|
||||
)
|
||||
}
|
||||
|
||||
static func tabId(from notification: Notification) -> UUID? {
|
||||
notification.userInfo?[tabIdKey] as? UUID
|
||||
}
|
||||
|
||||
static func reason(from notification: Notification) -> String {
|
||||
notification.userInfo?[reasonKey] as? String ?? "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarOutsideDropResetPolicy {
|
||||
static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool {
|
||||
draggedTabId != nil && hasSidebarDragPayload
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarDragFailsafePolicy {
|
||||
static let pollInterval: TimeInterval = 0.05
|
||||
static let clearDelay: TimeInterval = 0.15
|
||||
|
||||
static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool {
|
||||
isDragActive && !isLeftMouseButtonDown
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SidebarDragFailsafeMonitor: ObservableObject {
|
||||
private static let escapeKeyCode: UInt16 = 53
|
||||
private var timer: Timer?
|
||||
private var pendingClearWorkItem: DispatchWorkItem?
|
||||
private var appResignObserver: NSObjectProtocol?
|
||||
private var keyDownMonitor: Any?
|
||||
private var onRequestClear: ((String) -> Void)?
|
||||
|
||||
func start(onRequestClear: @escaping (String) -> Void) {
|
||||
self.onRequestClear = onRequestClear
|
||||
if timer == nil {
|
||||
let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
self.timer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
if appResignObserver == nil {
|
||||
appResignObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.requestClearSoon(reason: "app_resign_active")
|
||||
}
|
||||
}
|
||||
}
|
||||
if keyDownMonitor == nil {
|
||||
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
if event.keyCode == Self.escapeKeyCode {
|
||||
self?.requestClearSoon(reason: "escape_cancel")
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
pendingClearWorkItem?.cancel()
|
||||
pendingClearWorkItem = nil
|
||||
if let appResignObserver {
|
||||
NotificationCenter.default.removeObserver(appResignObserver)
|
||||
self.appResignObserver = nil
|
||||
}
|
||||
if let keyDownMonitor {
|
||||
NSEvent.removeMonitor(keyDownMonitor)
|
||||
self.keyDownMonitor = nil
|
||||
}
|
||||
onRequestClear = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
|
||||
guard SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true, // Monitor only runs while drag is active.
|
||||
isLeftMouseButtonDown: isLeftMouseButtonDown
|
||||
) else { return }
|
||||
requestClearSoon(reason: "mouse_up_failsafe")
|
||||
}
|
||||
|
||||
private func requestClearSoon(reason: String) {
|
||||
guard pendingClearWorkItem == nil else { return }
|
||||
#if DEBUG
|
||||
dlog("sidebar.dragFailsafe.schedule reason=\(reason)")
|
||||
#endif
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
#if DEBUG
|
||||
dlog("sidebar.dragFailsafe.fire reason=\(reason)")
|
||||
#endif
|
||||
self?.pendingClearWorkItem = nil
|
||||
self?.onRequestClear?(reason)
|
||||
}
|
||||
pendingClearWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarExternalDropOverlay: View {
|
||||
let draggedTabId: UUID?
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.allowsHitTesting(draggedTabId != nil)
|
||||
.onDrop(
|
||||
of: [SidebarTabDragPayload.typeIdentifier],
|
||||
delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarExternalDropDelegate: DropDelegate {
|
||||
let draggedTabId: UUID?
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier])
|
||||
let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: draggedTabId,
|
||||
hasSidebarDragPayload: hasSidebarPayload
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " +
|
||||
"hasType=\(hasSidebarPayload) allowed=\(shouldReset)"
|
||||
)
|
||||
#endif
|
||||
return shouldReset
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
#if DEBUG
|
||||
dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))")
|
||||
#endif
|
||||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
#if DEBUG
|
||||
dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))")
|
||||
#endif
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
guard validateDrop(info: info) else { return nil }
|
||||
#if DEBUG
|
||||
dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move")
|
||||
#endif
|
||||
// Explicit move proposal avoids AppKit showing a copy (+) cursor.
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
guard validateDrop(info: info) else { return false }
|
||||
#if DEBUG
|
||||
dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))")
|
||||
#endif
|
||||
SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop")
|
||||
return true
|
||||
}
|
||||
|
||||
private func debugShortSidebarTabId(_ id: UUID?) -> String {
|
||||
guard let id else { return "nil" }
|
||||
return String(id.uuidString.prefix(5))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SidebarCommandKeyMonitor: ObservableObject {
|
||||
@Published private(set) var isCommandPressed = false
|
||||
|
|
@ -1725,7 +1988,7 @@ private struct TabItemView: View {
|
|||
|
||||
Divider()
|
||||
|
||||
Button("Close Tabs") {
|
||||
Button("Close Workspaces") {
|
||||
closeTabs(targetIds, allowPinned: true)
|
||||
}
|
||||
.disabled(targetIds.isEmpty)
|
||||
|
|
@ -1735,12 +1998,12 @@ private struct TabItemView: View {
|
|||
}
|
||||
.disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count)
|
||||
|
||||
Button("Close Tabs Below") {
|
||||
Button("Close Workspaces Below") {
|
||||
closeTabsBelow(tabId: tab.id)
|
||||
}
|
||||
.disabled(index >= tabManager.tabs.count - 1)
|
||||
|
||||
Button("Close Tabs Above") {
|
||||
Button("Close Workspaces Above") {
|
||||
closeTabsAbove(tabId: tab.id)
|
||||
}
|
||||
.disabled(index == 0)
|
||||
|
|
@ -2386,6 +2649,9 @@ private struct SidebarTabDropDelegate: DropDelegate {
|
|||
}
|
||||
|
||||
func dropExited(info: DropInfo) {
|
||||
#if DEBUG
|
||||
dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
|
||||
#endif
|
||||
if dropIndicator?.tabId == targetTabId {
|
||||
dropIndicator = nil
|
||||
}
|
||||
|
|
@ -2394,6 +2660,12 @@ private struct SidebarTabDropDelegate: DropDelegate {
|
|||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
dragAutoScrollController.updateFromDragLocation()
|
||||
updateDropIndicator(for: info)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " +
|
||||
"indicator=\(debugIndicator(dropIndicator))"
|
||||
)
|
||||
#endif
|
||||
return DropProposal(operation: .move)
|
||||
}
|
||||
|
||||
|
|
@ -2406,8 +2678,18 @@ private struct SidebarTabDropDelegate: DropDelegate {
|
|||
#if DEBUG
|
||||
dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
|
||||
#endif
|
||||
guard let draggedTabId else { return false }
|
||||
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false }
|
||||
guard let draggedTabId else {
|
||||
#if DEBUG
|
||||
dlog("sidebar.drop.abort reason=missingDraggedTab")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else {
|
||||
#if DEBUG
|
||||
dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
let tabIds = tabManager.tabs.map(\.id)
|
||||
guard let targetIndex = SidebarDropPlanner.targetIndex(
|
||||
draggedTabId: draggedTabId,
|
||||
|
|
@ -2415,14 +2697,26 @@ private struct SidebarTabDropDelegate: DropDelegate {
|
|||
indicator: dropIndicator,
|
||||
tabIds: tabIds
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " +
|
||||
"target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
guard fromIndex != targetIndex else {
|
||||
#if DEBUG
|
||||
dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)")
|
||||
#endif
|
||||
syncSidebarSelection()
|
||||
return true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)")
|
||||
#endif
|
||||
_ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex)
|
||||
if let selectedId = tabManager.selectedTabId {
|
||||
selectedTabIds = [selectedId]
|
||||
|
|
@ -2453,6 +2747,12 @@ private struct SidebarTabDropDelegate: DropDelegate {
|
|||
lastSidebarSelectionIndex = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String {
|
||||
guard let indicator else { return "nil" }
|
||||
let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end"
|
||||
return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")"
|
||||
}
|
||||
}
|
||||
|
||||
/// AppKit-level double-click handler for the sidebar title-bar area.
|
||||
|
|
|
|||
|
|
@ -288,9 +288,7 @@ class GhosttyApp {
|
|||
|
||||
// Load default config (includes user config). If this fails hard (e.g. due to
|
||||
// invalid user config), ghostty_app_new may return nil; we fall back below.
|
||||
ghostty_config_load_default_files(primaryConfig)
|
||||
loadLegacyGhosttyConfigIfNeeded(primaryConfig)
|
||||
ghostty_config_finalize(primaryConfig)
|
||||
loadDefaultConfigFilesWithLegacyFallback(primaryConfig)
|
||||
updateDefaultBackground(from: primaryConfig)
|
||||
|
||||
// Create runtime config with callbacks
|
||||
|
|
@ -458,6 +456,21 @@ class GhosttyApp {
|
|||
#endif
|
||||
}
|
||||
|
||||
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
|
||||
ghostty_config_load_default_files(config)
|
||||
loadLegacyGhosttyConfigIfNeeded(config)
|
||||
ghostty_config_finalize(config)
|
||||
}
|
||||
|
||||
static func shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: Int?,
|
||||
legacyConfigFileSize: Int?
|
||||
) -> Bool {
|
||||
guard let newConfigFileSize, newConfigFileSize == 0 else { return false }
|
||||
guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
|
||||
#if os(macOS)
|
||||
// Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real
|
||||
|
|
@ -475,8 +488,10 @@ class GhosttyApp {
|
|||
return size.intValue
|
||||
}
|
||||
|
||||
guard let newSize = fileSize(configNew), newSize == 0 else { return }
|
||||
guard let legacySize = fileSize(configLegacy), legacySize > 0 else { return }
|
||||
guard Self.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: fileSize(configNew),
|
||||
legacyConfigFileSize: fileSize(configLegacy)
|
||||
) else { return }
|
||||
|
||||
configLegacy.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
|
|
@ -512,8 +527,7 @@ class GhosttyApp {
|
|||
}
|
||||
|
||||
guard let newConfig = ghostty_config_new() else { return }
|
||||
ghostty_config_load_default_files(newConfig)
|
||||
ghostty_config_finalize(newConfig)
|
||||
loadDefaultConfigFilesWithLegacyFallback(newConfig)
|
||||
ghostty_app_update_config(app, newConfig)
|
||||
updateDefaultBackground(from: newConfig)
|
||||
DispatchQueue.main.async {
|
||||
|
|
@ -533,8 +547,7 @@ class GhosttyApp {
|
|||
}
|
||||
|
||||
guard let newConfig = ghostty_config_new() else { return }
|
||||
ghostty_config_load_default_files(newConfig)
|
||||
ghostty_config_finalize(newConfig)
|
||||
loadDefaultConfigFilesWithLegacyFallback(newConfig)
|
||||
ghostty_surface_update_config(surface, newConfig)
|
||||
ghostty_config_free(newConfig)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ enum KeyboardShortcutSettings {
|
|||
case focusDown
|
||||
case splitRight
|
||||
case splitDown
|
||||
case splitBrowserRight
|
||||
case splitBrowserDown
|
||||
|
||||
// Panels
|
||||
case openBrowser
|
||||
|
|
@ -35,7 +37,7 @@ enum KeyboardShortcutSettings {
|
|||
var label: String {
|
||||
switch self {
|
||||
case .toggleSidebar: return "Toggle Sidebar"
|
||||
case .newTab: return "New Tab"
|
||||
case .newTab: return "New Workspace"
|
||||
case .newWindow: return "New Window"
|
||||
case .showNotifications: return "Show Notifications"
|
||||
case .jumpToUnread: return "Jump to Latest Unread"
|
||||
|
|
@ -51,6 +53,8 @@ enum KeyboardShortcutSettings {
|
|||
case .focusDown: return "Focus Pane Down"
|
||||
case .splitRight: return "Split Right"
|
||||
case .splitDown: return "Split Down"
|
||||
case .splitBrowserRight: return "Split Browser Right"
|
||||
case .splitBrowserDown: return "Split Browser Down"
|
||||
case .openBrowser: return "Open Browser"
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +75,8 @@ enum KeyboardShortcutSettings {
|
|||
case .focusDown: return "shortcut.focusDown"
|
||||
case .splitRight: return "shortcut.splitRight"
|
||||
case .splitDown: return "shortcut.splitDown"
|
||||
case .splitBrowserRight: return "shortcut.splitBrowserRight"
|
||||
case .splitBrowserDown: return "shortcut.splitBrowserDown"
|
||||
case .nextSurface: return "shortcut.nextSurface"
|
||||
case .prevSurface: return "shortcut.prevSurface"
|
||||
case .newSurface: return "shortcut.newSurface"
|
||||
|
|
@ -108,6 +114,10 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
|
||||
case .splitDown:
|
||||
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
|
||||
case .splitBrowserRight:
|
||||
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
|
||||
case .splitBrowserDown:
|
||||
return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false)
|
||||
case .nextSurface:
|
||||
return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false)
|
||||
case .prevSurface:
|
||||
|
|
@ -176,6 +186,8 @@ enum KeyboardShortcutSettings {
|
|||
|
||||
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
|
||||
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
|
||||
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
|
||||
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
|
||||
|
||||
static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) }
|
||||
static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) }
|
||||
|
|
|
|||
|
|
@ -1253,6 +1253,28 @@ class TabManager: ObservableObject {
|
|||
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
||||
}
|
||||
|
||||
/// Create a new browser split from the currently focused panel.
|
||||
@discardableResult
|
||||
func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
||||
let focusedPanelId = tab.focusedPanelId else { return nil }
|
||||
return newBrowserSplit(
|
||||
tabId: selectedTabId,
|
||||
fromPanelId: focusedPanelId,
|
||||
orientation: direction.orientation,
|
||||
insertFirst: direction.insertFirst,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
|
||||
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
|
||||
func refreshSplitButtonTooltips() {
|
||||
for workspace in tabs {
|
||||
workspace.refreshSplitButtonTooltips()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pane Focus Navigation
|
||||
|
||||
/// Move focus to an adjacent pane in the specified direction
|
||||
|
|
@ -1393,9 +1415,20 @@ class TabManager: ObservableObject {
|
|||
// MARK: - Browser Panel Operations
|
||||
|
||||
/// Create a new browser panel in a split
|
||||
func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? {
|
||||
func newBrowserSplit(
|
||||
tabId: UUID,
|
||||
fromPanelId: UUID,
|
||||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id
|
||||
return tab.newBrowserSplit(
|
||||
from: fromPanelId,
|
||||
orientation: orientation,
|
||||
insertFirst: insertFirst,
|
||||
url: url
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Create a new browser surface in a pane
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import Sparkle
|
||||
import Cocoa
|
||||
|
||||
enum UpdateFeedResolver {
|
||||
static let fallbackFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml"
|
||||
|
||||
static func resolvedFeedURLString(infoFeedURL: String?) -> (url: String, isNightly: Bool, usedFallback: Bool) {
|
||||
guard let infoFeedURL, !infoFeedURL.isEmpty else {
|
||||
return (fallbackFeedURL, false, true)
|
||||
}
|
||||
return (infoFeedURL, infoFeedURL.contains("/nightly/"), false)
|
||||
}
|
||||
}
|
||||
|
||||
extension UpdateDriver: SPUUpdaterDelegate {
|
||||
func feedURLString(for updater: SPUUpdater) -> String? {
|
||||
#if DEBUG
|
||||
|
|
@ -14,12 +25,11 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
// The feed URL is baked into Info.plist at build time:
|
||||
// - Stable releases use the stable appcast URL
|
||||
// - cmux NIGHTLY has the nightly appcast URL injected by CI
|
||||
let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
|
||||
let isNightly = feedURL?.contains("/nightly/") == true
|
||||
UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")")
|
||||
let usedFallback = feedURL == nil || feedURL?.isEmpty == true
|
||||
recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback)
|
||||
return feedURL
|
||||
let infoFeedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL)
|
||||
UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")")
|
||||
recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback)
|
||||
return infoFeedURL
|
||||
}
|
||||
|
||||
/// Called when an update is scheduled to install silently,
|
||||
|
|
|
|||
|
|
@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
||||
BonsplitConfiguration.SplitButtonTooltips(
|
||||
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
|
||||
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
|
||||
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
|
||||
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
|
||||
)
|
||||
}
|
||||
|
||||
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
|
||||
bonsplitAppearance(from: config.backgroundColor)
|
||||
}
|
||||
|
||||
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
|
||||
BonsplitConfiguration.Appearance(
|
||||
splitButtonTooltips: Self.currentSplitButtonTooltips(),
|
||||
enableAnimations: false,
|
||||
chromeColors: .init(backgroundHex: backgroundColor.hexString())
|
||||
)
|
||||
|
|
@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func refreshSplitButtonTooltips() {
|
||||
var configuration = bonsplitController.configuration
|
||||
configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips()
|
||||
bonsplitController.configuration = configuration
|
||||
}
|
||||
|
||||
// MARK: - Surface ID to Panel ID Mapping
|
||||
|
||||
/// Mapping from bonsplit TabID (surface ID) to panel UUID
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ struct cmuxApp: App {
|
|||
@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()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
|
|
@ -463,11 +465,19 @@ struct cmuxApp: App {
|
|||
performSplitFromMenu(direction: .down)
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) {
|
||||
performBrowserSplitFromMenu(direction: .right)
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) {
|
||||
performBrowserSplitFromMenu(direction: .down)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
||||
ForEach(1...9, id: \.self) { number in
|
||||
Button("Tab \(number)") {
|
||||
Button("Workspace \(number)") {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
|
||||
manager.selectTab(at: targetIndex)
|
||||
|
|
@ -545,6 +555,20 @@ struct cmuxApp: App {
|
|||
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
|
||||
}
|
||||
|
||||
private var splitBrowserRightMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: splitBrowserRightShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var splitBrowserDownMenuShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: splitBrowserDownShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var notificationMenuSnapshot: NotificationMenuSnapshot {
|
||||
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
||||
}
|
||||
|
|
@ -577,6 +601,13 @@ struct cmuxApp: App {
|
|||
tabManager.createSplit(direction: direction)
|
||||
}
|
||||
|
||||
private func performBrowserSplitFromMenu(direction: SplitDirection) {
|
||||
if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true {
|
||||
return
|
||||
}
|
||||
_ = tabManager.createBrowserSplit(direction: direction)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
|
||||
if let key = keyEquivalent(for: shortcut) {
|
||||
|
|
|
|||
|
|
@ -225,6 +225,74 @@ final class ShortcutHintDebugSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class KeyboardShortcutSettingsTests: XCTestCase {
|
||||
func testBrowserSplitShortcutDefaults() {
|
||||
let keys = [
|
||||
KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey,
|
||||
KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey
|
||||
]
|
||||
let defaults = UserDefaults.standard
|
||||
let previousValues = keys.map { key in (key, defaults.data(forKey: key)) }
|
||||
defer {
|
||||
for (key, value) in previousValues {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
keys.forEach { defaults.removeObject(forKey: $0) }
|
||||
|
||||
XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserRight).displayString, "⌥⌘D")
|
||||
XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserDown).displayString, "⌥⇧⌘D")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testWorkspaceConfiguresSplitButtonTooltipsWithEffectiveShortcuts() throws {
|
||||
let keys = [
|
||||
KeyboardShortcutSettings.Action.newSurface.defaultsKey,
|
||||
KeyboardShortcutSettings.Action.openBrowser.defaultsKey,
|
||||
KeyboardShortcutSettings.Action.splitRight.defaultsKey,
|
||||
KeyboardShortcutSettings.Action.splitDown.defaultsKey
|
||||
]
|
||||
let defaults = UserDefaults.standard
|
||||
let previousValues = keys.map { key in (key, defaults.data(forKey: key)) }
|
||||
defer {
|
||||
for (key, value) in previousValues {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let customPairs: [(KeyboardShortcutSettings.Action, StoredShortcut)] = [
|
||||
(.newSurface, StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)),
|
||||
(.openBrowser, StoredShortcut(key: "2", command: true, shift: false, option: false, control: false)),
|
||||
(.splitRight, StoredShortcut(key: "3", command: true, shift: false, option: false, control: false)),
|
||||
(.splitDown, StoredShortcut(key: "4", command: true, shift: false, option: false, control: false)),
|
||||
]
|
||||
|
||||
for (action, shortcut) in customPairs {
|
||||
guard let data = try? JSONEncoder().encode(shortcut) else {
|
||||
XCTFail("Failed to encode shortcut for \(action.rawValue)")
|
||||
return
|
||||
}
|
||||
defaults.set(data, forKey: action.defaultsKey)
|
||||
}
|
||||
|
||||
let workspace = Workspace(title: "Tooltip Test")
|
||||
let tooltips = workspace.bonsplitController.configuration.appearance.splitButtonTooltips
|
||||
|
||||
XCTAssertEqual(tooltips.newTerminal, "New Terminal (⌘1)")
|
||||
XCTAssertEqual(tooltips.newBrowser, "New Browser (⌘2)")
|
||||
XCTAssertEqual(tooltips.splitRight, "Split Right (⌘3)")
|
||||
XCTAssertEqual(tooltips.splitDown, "Split Down (⌘4)")
|
||||
}
|
||||
}
|
||||
|
||||
final class ShortcutHintLanePlannerTests: XCTestCase {
|
||||
func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() {
|
||||
let intervals: [ClosedRange<CGFloat>] = [0...20, 28...40, 48...64]
|
||||
|
|
@ -390,75 +458,26 @@ final class AppearanceSettingsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
// Compatibility shim for update-channel tests while feed selection is sourced from Info.plist.
|
||||
private enum UpdateChannelSettings {
|
||||
static let includeNightlyBuildsKey = "includeNightlyBuilds"
|
||||
static let defaultIncludeNightlyBuilds = false
|
||||
static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml"
|
||||
static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
|
||||
|
||||
static func resolvedFeedURLString(infoFeedURL: String?, defaults: UserDefaults) -> (url: String, isNightly: Bool, usedFallback: Bool) {
|
||||
let includeNightlyBuilds = defaults.object(forKey: includeNightlyBuildsKey) as? Bool ?? defaultIncludeNightlyBuilds
|
||||
if includeNightlyBuilds {
|
||||
return (nightlyFeedURL, true, false)
|
||||
}
|
||||
|
||||
if let infoFeedURL, !infoFeedURL.isEmpty {
|
||||
return (infoFeedURL, false, false)
|
||||
}
|
||||
|
||||
return (stableFeedURL, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdateChannelSettingsTests: XCTestCase {
|
||||
func testDefaultNightlyPreferenceIsDisabled() {
|
||||
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
|
||||
}
|
||||
|
||||
final class UpdateFeedResolverTests: XCTestCase {
|
||||
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
|
||||
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
|
||||
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertTrue(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedUsesInfoFeedForStableChannel() {
|
||||
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let infoFeed = "https://example.com/custom/appcast.xml"
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
|
||||
XCTAssertEqual(resolved.url, infoFeed)
|
||||
XCTAssertFalse(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
||||
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
|
||||
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
|
||||
let resolved = UpdateChannelSettings.resolvedFeedURLString(
|
||||
infoFeedURL: "https://example.com/custom/appcast.xml",
|
||||
defaults: defaults
|
||||
)
|
||||
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
|
||||
func testResolvedFeedDetectsNightlyChannelFromInfoFeed() {
|
||||
let infoFeed = "https://example.com/nightly/appcast.xml"
|
||||
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
|
||||
XCTAssertEqual(resolved.url, infoFeed)
|
||||
XCTAssertTrue(resolved.isNightly)
|
||||
XCTAssertFalse(resolved.usedFallback)
|
||||
}
|
||||
|
|
@ -802,6 +821,54 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class SidebarOutsideDropResetPolicyTests: XCTestCase {
|
||||
func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() {
|
||||
let tabId = UUID()
|
||||
|
||||
XCTAssertTrue(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: tabId,
|
||||
hasSidebarDragPayload: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: nil,
|
||||
hasSidebarDragPayload: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarOutsideDropResetPolicy.shouldResetDrag(
|
||||
draggedTabId: tabId,
|
||||
hasSidebarDragPayload: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDragFailsafePolicyTests: XCTestCase {
|
||||
func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() {
|
||||
XCTAssertTrue(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true,
|
||||
isLeftMouseButtonDown: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: true,
|
||||
isLeftMouseButtonDown: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
SidebarDragFailsafePolicy.shouldRequestClear(
|
||||
isDragActive: false,
|
||||
isLeftMouseButtonDown: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
|
||||
func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
|
||||
let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
|
||||
|
|
|
|||
|
|
@ -126,6 +126,42 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
|
||||
}
|
||||
|
||||
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: 0,
|
||||
legacyConfigFileSize: 42
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() {
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: nil,
|
||||
legacyConfigFileSize: 42
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: 10,
|
||||
legacyConfigFileSize: 42
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: 0,
|
||||
legacyConfigFileSize: 0
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: 0,
|
||||
legacyConfigFileSize: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func rgb255(_ color: NSColor) -> RGB {
|
||||
let srgb = color.usingColorSpace(.sRGB)!
|
||||
var red: CGFloat = 0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
|
@ -16,13 +16,53 @@ if ! command -v zig &> /dev/null; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
|
||||
cd ghostty
|
||||
zig build -Demit-xcframework=true -Doptimize=ReleaseFast
|
||||
cd "$PROJECT_DIR"
|
||||
GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)"
|
||||
CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}"
|
||||
CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA"
|
||||
CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework"
|
||||
LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework"
|
||||
LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock"
|
||||
|
||||
mkdir -p "$CACHE_ROOT"
|
||||
|
||||
echo "==> Ghostty submodule commit: $GHOSTTY_SHA"
|
||||
|
||||
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
|
||||
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..."
|
||||
sleep 1
|
||||
done
|
||||
trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
if [ -d "$CACHE_XCFRAMEWORK" ]; then
|
||||
echo "==> Reusing cached GhosttyKit.xcframework"
|
||||
else
|
||||
if [ -d "$LOCAL_XCFRAMEWORK" ]; then
|
||||
echo "==> Seeding cache from existing local GhosttyKit.xcframework"
|
||||
else
|
||||
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
|
||||
(
|
||||
cd ghostty
|
||||
zig build -Demit-xcframework=true -Doptimize=ReleaseFast
|
||||
)
|
||||
fi
|
||||
|
||||
SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK"
|
||||
if [ ! -d "$SRC_XCFRAMEWORK" ]; then
|
||||
echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cp -R "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
|
||||
rm -rf "$CACHE_XCFRAMEWORK"
|
||||
mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK"
|
||||
rmdir "$TMP_DIR"
|
||||
echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK"
|
||||
fi
|
||||
|
||||
echo "==> Creating symlink for GhosttyKit.xcframework..."
|
||||
ln -sf ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
|
||||
ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework
|
||||
|
||||
echo "==> Setup complete!"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -104,6 +104,34 @@ up?.post(tap: .cghidEventTap)
|
|||
)
|
||||
|
||||
|
||||
def post_scroll_with_cgevent(x: float, y: float, delta_y: int = 3) -> None:
|
||||
ix = int(round(x))
|
||||
iy = int(round(y))
|
||||
code = f"""
|
||||
import CoreGraphics
|
||||
let p = CGPoint(x: {ix}, y: {iy})
|
||||
let source = CGEventSource(stateID: .hidSystemState)
|
||||
if let scroll = CGEvent(
|
||||
scrollWheelEvent2Source: source,
|
||||
units: .line,
|
||||
wheelCount: 1,
|
||||
wheel1: Int32({delta_y}),
|
||||
wheel2: 0,
|
||||
wheel3: 0
|
||||
) {{
|
||||
scroll.location = p
|
||||
scroll.post(tap: .cghidEventTap)
|
||||
}}
|
||||
"""
|
||||
subprocess.run(
|
||||
["swift", "-e", code],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def pick_top_bottom_terminal_panels(layout: dict) -> tuple[dict, dict]:
|
||||
candidates = []
|
||||
for panel in layout.get("selectedPanels", []):
|
||||
|
|
@ -282,7 +310,14 @@ def main() -> int:
|
|||
print("FAIL: real right click disrupted terminal focus routing")
|
||||
return 1
|
||||
|
||||
print("PASS: stale file-drag overlay forwards real left/right clicks")
|
||||
for _ in range(6):
|
||||
post_scroll_with_cgevent(click_x, click_y, delta_y=2)
|
||||
time.sleep(0.25)
|
||||
if not client.is_terminal_focused(bottom_id):
|
||||
print("FAIL: real scroll wheel disrupted terminal focus routing")
|
||||
return 1
|
||||
|
||||
print("PASS: stale file-drag overlay forwards real left/right clicks and scroll")
|
||||
print(f" focused_panel={bottom_id}")
|
||||
return 0
|
||||
finally:
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
|
||||
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745
|
||||
|
|
@ -61,15 +61,26 @@ export default function ApiPage() {
|
|||
<code>/tmp/cmux-debug.sock</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tagged debug build</td>
|
||||
<td>
|
||||
<code>/tmp/cmux-debug-<tag>.sock</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Override with the <code>CMUX_SOCKET_PATH</code> environment variable.
|
||||
Commands are newline-terminated JSON:
|
||||
Send one newline-terminated JSON request per call:
|
||||
</p>
|
||||
<CodeBlock lang="json">{`{"command": "command-name", "arg1": "value1"}
|
||||
<CodeBlock lang="json">{`{"id":"req-1","method":"workspace.list","params":{}}
|
||||
// Response:
|
||||
{"success": true, "data": {...}}`}</CodeBlock>
|
||||
{"id":"req-1","ok":true,"result":{"workspaces":[...]}}`}</CodeBlock>
|
||||
<Callout>
|
||||
JSON socket requests must use <code>method</code> and{" "}
|
||||
<code>params</code>. Legacy v1 JSON payloads such as{" "}
|
||||
<code>{`{"command":"..."}`}</code> are not supported.
|
||||
</Callout>
|
||||
|
||||
<h2>Access modes</h2>
|
||||
<table>
|
||||
|
|
@ -77,6 +88,7 @@ export default function ApiPage() {
|
|||
<tr>
|
||||
<th>Mode</th>
|
||||
<th>Description</th>
|
||||
<th>How to enable</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -85,24 +97,31 @@ export default function ApiPage() {
|
|||
<strong>Off</strong>
|
||||
</td>
|
||||
<td>Socket disabled</td>
|
||||
<td>Settings UI or <code>CMUX_SOCKET_MODE=off</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Notifications only</strong>
|
||||
<strong>cmux processes only</strong>
|
||||
</td>
|
||||
<td>Only notification commands allowed</td>
|
||||
<td>
|
||||
Only processes spawned inside cmux terminals can connect.
|
||||
</td>
|
||||
<td>Default mode in Settings UI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Full control</strong>
|
||||
<strong>allowAll</strong>
|
||||
</td>
|
||||
<td>Allow any local process to connect (no ancestry check).</td>
|
||||
<td>
|
||||
Environment override only: <code>CMUX_SOCKET_MODE=allowAll</code>
|
||||
</td>
|
||||
<td>All commands enabled</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Callout type="warn">
|
||||
On shared machines, use “Notifications only” mode to prevent
|
||||
other users from controlling your terminals.
|
||||
On shared machines, use <strong>Off</strong> or{" "}
|
||||
<strong>cmux processes only</strong>.
|
||||
</Callout>
|
||||
|
||||
<h2>CLI options</h2>
|
||||
|
|
@ -126,6 +145,12 @@ export default function ApiPage() {
|
|||
</td>
|
||||
<td>Output in JSON format</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--window ID</code>
|
||||
</td>
|
||||
<td>Target a specific window</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--workspace ID</code>
|
||||
|
|
@ -138,6 +163,12 @@ export default function ApiPage() {
|
|||
</td>
|
||||
<td>Target a specific surface</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>--id-format refs|uuids|both</code>
|
||||
</td>
|
||||
<td>Control identifier format in JSON output</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
|
@ -148,32 +179,32 @@ export default function ApiPage() {
|
|||
desc="List all open workspaces."
|
||||
cli={`cmux list-workspaces
|
||||
cmux list-workspaces --json`}
|
||||
socket={`{"command": "list-workspaces"}`}
|
||||
socket={`{"id":"ws-list","method":"workspace.list","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="new-workspace"
|
||||
desc="Create a new workspace."
|
||||
cli={`cmux new-workspace`}
|
||||
socket={`{"command": "new-workspace"}`}
|
||||
socket={`{"id":"ws-new","method":"workspace.create","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="select-workspace"
|
||||
desc="Switch to a specific workspace."
|
||||
cli={`cmux select-workspace --workspace <id>`}
|
||||
socket={`{"command": "select-workspace", "id": "<id>"}`}
|
||||
socket={`{"id":"ws-select","method":"workspace.select","params":{"workspace_id":"<id>"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="current-workspace"
|
||||
desc="Get the currently active workspace."
|
||||
cli={`cmux current-workspace
|
||||
cmux current-workspace --json`}
|
||||
socket={`{"command": "current-workspace"}`}
|
||||
socket={`{"id":"ws-current","method":"workspace.current","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="close-workspace"
|
||||
desc="Close a workspace."
|
||||
cli={`cmux close-workspace --workspace <id>`}
|
||||
socket={`{"command": "close-workspace", "id": "<id>"}`}
|
||||
socket={`{"id":"ws-close","method":"workspace.close","params":{"workspace_id":"<id>"}}`}
|
||||
/>
|
||||
|
||||
<h2>Split commands</h2>
|
||||
|
|
@ -183,20 +214,20 @@ cmux current-workspace --json`}
|
|||
desc="Create a new split pane. Directions: left, right, up, down."
|
||||
cli={`cmux new-split right
|
||||
cmux new-split down`}
|
||||
socket={`{"command": "new-split", "direction": "right"}`}
|
||||
socket={`{"id":"split-new","method":"surface.split","params":{"direction":"right"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="list-surfaces"
|
||||
desc="List all surfaces in the current workspace."
|
||||
cli={`cmux list-surfaces
|
||||
cmux list-surfaces --json`}
|
||||
socket={`{"command": "list-surfaces"}`}
|
||||
socket={`{"id":"surface-list","method":"surface.list","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="focus-surface"
|
||||
desc="Focus a specific surface."
|
||||
cli={`cmux focus-surface --surface <id>`}
|
||||
socket={`{"command": "focus-surface", "id": "<id>"}`}
|
||||
socket={`{"id":"surface-focus","method":"surface.focus","params":{"surface_id":"<id>"}}`}
|
||||
/>
|
||||
|
||||
<h2>Input commands</h2>
|
||||
|
|
@ -206,25 +237,25 @@ cmux list-surfaces --json`}
|
|||
desc="Send text input to the focused terminal."
|
||||
cli={`cmux send "echo hello"
|
||||
cmux send "ls -la\\n"`}
|
||||
socket={`{"command": "send", "text": "echo hello\\n"}`}
|
||||
socket={`{"id":"send-text","method":"surface.send_text","params":{"text":"echo hello\\n"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="send-key"
|
||||
desc="Send a key press. Keys: enter, tab, escape, backspace, delete, up, down, left, right."
|
||||
cli={`cmux send-key enter`}
|
||||
socket={`{"command": "send-key", "key": "enter"}`}
|
||||
socket={`{"id":"send-key","method":"surface.send_key","params":{"key":"enter"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="send-surface"
|
||||
desc="Send text to a specific surface."
|
||||
cli={`cmux send-surface --surface <id> "command"`}
|
||||
socket={`{"command": "send-surface", "id": "<id>", "text": "command"}`}
|
||||
socket={`{"id":"send-surface","method":"surface.send_text","params":{"surface_id":"<id>","text":"command"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="send-key-surface"
|
||||
desc="Send a key press to a specific surface."
|
||||
cli={`cmux send-key-surface --surface <id> enter`}
|
||||
socket={`{"command": "send-key-surface", "id": "<id>", "key": "enter"}`}
|
||||
socket={`{"id":"send-key-surface","method":"surface.send_key","params":{"surface_id":"<id>","key":"enter"}}`}
|
||||
/>
|
||||
|
||||
<h2>Notification commands</h2>
|
||||
|
|
@ -234,21 +265,20 @@ cmux send "ls -la\\n"`}
|
|||
desc="Send a notification."
|
||||
cli={`cmux notify --title "Title" --body "Body"
|
||||
cmux notify --title "T" --subtitle "S" --body "B"`}
|
||||
socket={`{"command": "notify", "title": "Title",
|
||||
"subtitle": "S", "body": "Body"}`}
|
||||
socket={`{"id":"notify","method":"notification.create","params":{"title":"Title","subtitle":"S","body":"Body"}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="list-notifications"
|
||||
desc="List all notifications."
|
||||
cli={`cmux list-notifications
|
||||
cmux list-notifications --json`}
|
||||
socket={`{"command": "list-notifications"}`}
|
||||
socket={`{"id":"notif-list","method":"notification.list","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="clear-notifications"
|
||||
desc="Clear all notifications."
|
||||
cli={`cmux clear-notifications`}
|
||||
socket={`{"command": "clear-notifications"}`}
|
||||
socket={`{"id":"notif-clear","method":"notification.clear","params":{}}`}
|
||||
/>
|
||||
|
||||
<h2>Utility commands</h2>
|
||||
|
|
@ -257,8 +287,22 @@ cmux list-notifications --json`}
|
|||
name="ping"
|
||||
desc="Check if cmux is running and responsive."
|
||||
cli={`cmux ping`}
|
||||
socket={`{"command": "ping"}
|
||||
// Response: {"success": true, "pong": true}`}
|
||||
socket={`{"id":"ping","method":"system.ping","params":{}}
|
||||
// Response: {"id":"ping","ok":true,"result":{"pong":true}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="capabilities"
|
||||
desc="List available socket methods and current access mode."
|
||||
cli={`cmux capabilities
|
||||
cmux capabilities --json`}
|
||||
socket={`{"id":"caps","method":"system.capabilities","params":{}}`}
|
||||
/>
|
||||
<Cmd
|
||||
name="identify"
|
||||
desc="Show focused window/workspace/pane/surface context."
|
||||
cli={`cmux identify
|
||||
cmux identify --json`}
|
||||
socket={`{"id":"identify","method":"system.identify","params":{}}`}
|
||||
/>
|
||||
|
||||
<h2>Environment variables</h2>
|
||||
|
|
@ -274,14 +318,16 @@ cmux list-notifications --json`}
|
|||
<td>
|
||||
<code>CMUX_SOCKET_PATH</code>
|
||||
</td>
|
||||
<td>Override the default socket path</td>
|
||||
<td>Override the socket path used by CLI and integrations</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>CMUX_SOCKET_ENABLE</code>
|
||||
</td>
|
||||
<td>
|
||||
Enable/disable socket (<code>1</code>/<code>0</code>)
|
||||
Force-enable/disable socket (<code>1</code>/<code>0</code>,{" "}
|
||||
<code>true</code>/<code>false</code>, <code>on</code>/
|
||||
<code>off</code>)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -289,8 +335,10 @@ cmux list-notifications --json`}
|
|||
<code>CMUX_SOCKET_MODE</code>
|
||||
</td>
|
||||
<td>
|
||||
Override access mode (<code>full</code>,{" "}
|
||||
<code>notifications</code>, <code>off</code>)
|
||||
Override access mode (<code>cmuxOnly</code>,{" "}
|
||||
<code>allowAll</code>, <code>off</code>). Also accepts{" "}
|
||||
<code>cmux-only</code>/<code>cmux_only</code> and{" "}
|
||||
<code>allow-all</code>/<code>allow_all</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -324,51 +372,60 @@ cmux list-notifications --json`}
|
|||
</tbody>
|
||||
</table>
|
||||
<Callout>
|
||||
Environment variables override app settings. Use the socket check to
|
||||
distinguish cmux from regular Ghostty.
|
||||
Legacy <code>CMUX_SOCKET_MODE</code> values <code>full</code> and{" "}
|
||||
<code>notifications</code> are still accepted for compatibility.
|
||||
</Callout>
|
||||
|
||||
<h2>Detecting cmux</h2>
|
||||
<CodeBlock title="bash" lang="bash">{`# Check for the socket
|
||||
[ -S /tmp/cmux.sock ] && echo "In cmux"
|
||||
<CodeBlock title="bash" lang="bash">{`# Prefer explicit socket path if set
|
||||
SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}"
|
||||
[ -S "$SOCK" ] && echo "Socket available"
|
||||
|
||||
# Check for the CLI
|
||||
command -v cmux &>/dev/null && echo "cmux available"
|
||||
|
||||
# In cmux-managed terminals these are auto-set
|
||||
[ -n "\${CMUX_WORKSPACE_ID:-}" ] && [ -n "\${CMUX_SURFACE_ID:-}" ] && echo "Inside cmux surface"
|
||||
|
||||
# Distinguish from regular Ghostty
|
||||
[ "$TERM_PROGRAM" = "ghostty" ] && [ -S /tmp/cmux.sock ] && echo "In cmux"`}</CodeBlock>
|
||||
[ "$TERM_PROGRAM" = "ghostty" ] && [ -n "\${CMUX_WORKSPACE_ID:-}" ] && echo "In cmux"`}</CodeBlock>
|
||||
|
||||
<h2>Examples</h2>
|
||||
|
||||
<h3>Python client</h3>
|
||||
<CodeBlock title="python" lang="python">{`import socket, json
|
||||
<CodeBlock title="python" lang="python">{`import json
|
||||
import os
|
||||
import socket
|
||||
|
||||
def send_command(cmd):
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect('/tmp/cmux.sock')
|
||||
sock.send(json.dumps(cmd).encode() + b'\\n')
|
||||
response = sock.recv(4096).decode()
|
||||
sock.close()
|
||||
return json.loads(response)
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux.sock")
|
||||
|
||||
def rpc(method, params=None, req_id=1):
|
||||
payload = {"id": req_id, "method": method, "params": params or {}}
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(SOCKET_PATH)
|
||||
sock.sendall(json.dumps(payload).encode("utf-8") + b"\\n")
|
||||
return json.loads(sock.recv(65536).decode("utf-8"))
|
||||
|
||||
# List workspaces
|
||||
print(send_command({"command": "list-workspaces"}))
|
||||
print(rpc("workspace.list", req_id="ws"))
|
||||
|
||||
# Send notification
|
||||
send_command({
|
||||
"command": "notify",
|
||||
"title": "Hello",
|
||||
"body": "From Python!"
|
||||
})`}</CodeBlock>
|
||||
print(rpc(
|
||||
"notification.create",
|
||||
{"title": "Hello", "body": "From Python!"},
|
||||
req_id="notify"
|
||||
))`}</CodeBlock>
|
||||
|
||||
<h3>Shell script</h3>
|
||||
<CodeBlock title="bash" lang="bash">{`#!/bin/bash
|
||||
SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}"
|
||||
|
||||
cmux_cmd() {
|
||||
echo "$1" | nc -U /tmp/cmux.sock
|
||||
printf "%s\\n" "$1" | nc -U "$SOCK"
|
||||
}
|
||||
|
||||
cmux_cmd '{"command": "list-workspaces"}'
|
||||
cmux_cmd '{"command": "notify", "title": "Done", "body": "Task complete"}'`}</CodeBlock>
|
||||
cmux_cmd '{"id":"ws","method":"workspace.list","params":{}}'
|
||||
cmux_cmd '{"id":"notify","method":"notification.create","params":{"title":"Done","body":"Task complete"}}'`}</CodeBlock>
|
||||
|
||||
<h3>Build script with notification</h3>
|
||||
<CodeBlock title="bash" lang="bash">{`#!/bin/bash
|
||||
|
|
|
|||
|
|
@ -95,15 +95,17 @@ working-directory = ~/Projects`}</CodeBlock>
|
|||
<strong>Off</strong> — no socket control (most secure)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notifications only</strong> — only allow notification commands
|
||||
<strong>cmux processes only</strong> — only allow processes started
|
||||
inside cmux terminals to connect
|
||||
</li>
|
||||
<li>
|
||||
<strong>Full control</strong> — allow all socket commands
|
||||
<strong>allowAll</strong> — allow any local process to connect (
|
||||
<code>CMUX_SOCKET_MODE=allowAll</code>, env override only)
|
||||
</li>
|
||||
</ul>
|
||||
<Callout type="warn">
|
||||
On shared machines, consider using “Notifications only” mode
|
||||
to prevent other processes from controlling your terminals.
|
||||
On shared machines, consider using “Off” or
|
||||
“cmux processes only” mode.
|
||||
</Callout>
|
||||
|
||||
<h2>Example config</h2>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [
|
|||
combos: [["⌥", "⌘", "←/→/↑/↓"]],
|
||||
description: "Focus pane directionally",
|
||||
},
|
||||
{
|
||||
id: "sp-browser-right",
|
||||
combos: [["⌥", "⌘", "D"]],
|
||||
description: "Split browser right",
|
||||
},
|
||||
{
|
||||
id: "sp-browser-down",
|
||||
combos: [["⌥", "⌘", "⇧", "D"]],
|
||||
description: "Split browser down",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [
|
|||
shortcuts: [
|
||||
{
|
||||
id: "br-open",
|
||||
combos: [["⌘", "⇧", "B"]],
|
||||
description: "Open browser in split",
|
||||
combos: [["⌘", "⇧", "L"]],
|
||||
description: "Open browser surface",
|
||||
},
|
||||
{ id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" },
|
||||
{ id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue