Merge origin/main into feat-sidebar-branch-refresh-on-close

This commit is contained in:
Lawrence Chen 2026-02-20 23:36:11 -08:00
commit a2c39802d1
15 changed files with 1189 additions and 105 deletions

View file

@ -3031,7 +3031,7 @@ struct CMUXCLI {
new-terminal-right | new-browser-right
reload | duplicate
pin | unpin
mark-unread
mark-read | mark-unread
Flags:
--action <name> Action name (required if not positional)

View file

@ -22,8 +22,12 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String {
final class WindowBrowserHostView: NSView {
override var isOpaque: Bool { false }
private var cachedSidebarDividerX: CGFloat?
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
@ -31,6 +35,30 @@ final class WindowBrowserHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// Ignore transient 0-origin slots during layout churn and preserve the last
// known-good divider edge.
let dividerCandidates = visibleSlots
.map(\.frame.minX)
.filter { $0 > 1 }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)

View file

@ -159,6 +159,15 @@ final class SidebarState: ObservableObject {
}
}
enum SidebarResizeInteraction {
static let handleWidth: CGFloat = 6
static let hitInset: CGFloat = 3
static var hitWidthPerSide: CGFloat {
hitInset + (handleWidth / 2)
}
}
// MARK: - File Drop Overlay
enum DragOverlayRoutingPolicy {
@ -272,6 +281,8 @@ final class FileDropOverlayView: NSView {
/// Fallback handler when no terminal is found under the drop point.
var onDrop: (([URL]) -> Bool)?
private var isForwardingMouseEvent = false
private weak var forwardedMouseDragTarget: NSView?
private var forwardedMouseDragButton: ForwardedMouseDragButton?
/// The WKWebView currently receiving forwarded drag events, so we can
/// synthesize draggingExited/draggingEntered as the cursor moves.
private weak var activeDragWebView: WKWebView?
@ -287,6 +298,43 @@ final class FileDropOverlayView: NSView {
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
private enum ForwardedMouseDragButton: Equatable {
case left
case right
case other(Int)
}
private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? {
switch event.type {
case .leftMouseDown, .leftMouseUp, .leftMouseDragged:
return .left
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
return .right
case .otherMouseDown, .otherMouseUp, .otherMouseDragged:
return .other(Int(event.buttonNumber))
default:
return nil
}
}
private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
return true
default:
return false
}
}
private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseUp, .rightMouseUp, .otherMouseUp:
return true
default:
return false
}
}
// MARK: Hit-testing participation is routed by DragOverlayRoutingPolicy so
// file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict.
@ -317,6 +365,7 @@ final class FileDropOverlayView: NSView {
private func forwardEvent(_ event: NSEvent) {
guard !isForwardingMouseEvent else { return }
guard let window, let contentView = window.contentView else { return }
let eventButton = dragButton(for: event)
isForwardingMouseEvent = true
isHidden = true
@ -325,9 +374,33 @@ final class FileDropOverlayView: NSView {
isForwardingMouseEvent = false
}
let point = contentView.convert(event.locationInWindow, from: nil)
let target = contentView.hitTest(point)
guard let target, target !== self else { return }
let target: NSView?
if let eventButton,
forwardedMouseDragButton == eventButton,
let activeTarget = forwardedMouseDragTarget,
activeTarget.window != nil {
// Preserve normal AppKit mouse-delivery semantics: once a drag starts,
// keep routing dragged/up events to the original mouseDown target.
target = activeTarget
} else {
let point = contentView.convert(event.locationInWindow, from: nil)
target = contentView.hitTest(point)
}
guard let target, target !== self else {
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
return
}
if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton {
forwardedMouseDragTarget = target
forwardedMouseDragButton = eventButton
}
switch event.type {
case .leftMouseDown: target.mouseDown(with: event)
@ -342,6 +415,13 @@ final class FileDropOverlayView: NSView {
case .scrollWheel: target.scrollWheel(with: event)
default: break
}
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
}
}
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
@ -723,10 +803,9 @@ struct ContentView: View {
@EnvironmentObject var sidebarState: SidebarState
@EnvironmentObject var sidebarSelectionState: SidebarSelectionState
@State private var sidebarWidth: CGFloat = 200
@State private var sidebarMinX: CGFloat = 0
@State private var isResizerHovering = false
@State private var hoveredResizerHandles: Set<SidebarResizerHandle> = []
@State private var isResizerDragging = false
private let sidebarHandleWidth: CGFloat = 6
@State private var sidebarDragStartWidth: CGFloat?
@State private var selectedTabIds: Set<UUID> = []
@State private var mountedWorkspaceIds: [UUID] = []
@State private var lastSidebarSelectionIndex: Int? = nil
@ -742,6 +821,252 @@ struct ContentView: View {
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem?
@State private var sidebarResizerPointerMonitor: Any?
@State private var isResizerBandActive = false
@State private var sidebarResizerCursorStabilizer: DispatchSourceTimer?
private static let fixedSidebarResizeCursor = NSCursor(
image: NSCursor.resizeLeftRight.image,
hotSpot: NSCursor.resizeLeftRight.hotSpot
)
private enum SidebarResizerHandle: Hashable {
case divider
}
private var sidebarResizerHitWidthPerSide: CGFloat {
SidebarResizeInteraction.hitWidthPerSide
}
private var maxSidebarWidth: CGFloat {
(NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
}
private func activateSidebarResizerCursor() {
sidebarResizerCursorReleaseWorkItem?.cancel()
sidebarResizerCursorReleaseWorkItem = nil
Self.fixedSidebarResizeCursor.set()
}
private func releaseSidebarResizerCursorIfNeeded(force: Bool = false) {
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
let shouldKeepCursor = !force
&& (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown)
guard !shouldKeepCursor else { return }
NSCursor.arrow.set()
}
private func scheduleSidebarResizerCursorRelease(force: Bool = false, delay: TimeInterval = 0) {
sidebarResizerCursorReleaseWorkItem?.cancel()
let workItem = DispatchWorkItem {
sidebarResizerCursorReleaseWorkItem = nil
releaseSidebarResizerCursorIfNeeded(force: force)
}
sidebarResizerCursorReleaseWorkItem = workItem
if delay > 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
} else {
DispatchQueue.main.async(execute: workItem)
}
}
private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool {
guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false }
let minX = sidebarWidth - sidebarResizerHitWidthPerSide
let maxX = sidebarWidth + sidebarResizerHitWidthPerSide
return point.x >= minX && point.x <= maxX
}
private func updateSidebarResizerBandState(using event: NSEvent? = nil) {
guard sidebarState.isVisible,
let window = observedWindow,
let contentView = window.contentView else {
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
return
}
// Use live global pointer location instead of per-event coordinates.
// Overlapping tracking areas (notably WKWebView) can deliver stale/jittery
// event locations during cursor updates, which causes visible cursor flicker.
let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
let pointInContent = contentView.convert(pointInWindow, from: nil)
let isInDividerBand = dividerBandContains(pointInContent: pointInContent, contentBounds: contentView.bounds)
isResizerBandActive = isInDividerBand
if isInDividerBand || isResizerDragging {
activateSidebarResizerCursor()
startSidebarResizerCursorStabilizer()
// AppKit cursorUpdate handlers from overlapped portal/web views can run
// after our local monitor callback and temporarily reset the cursor.
// Re-assert on the next runloop turn to keep the resize cursor stable.
DispatchQueue.main.async {
Self.fixedSidebarResizeCursor.set()
}
} else {
stopSidebarResizerCursorStabilizer()
scheduleSidebarResizerCursorRelease()
}
}
private func startSidebarResizerCursorStabilizer() {
guard sidebarResizerCursorStabilizer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2))
timer.setEventHandler {
updateSidebarResizerBandState()
if isResizerBandActive || isResizerDragging {
Self.fixedSidebarResizeCursor.set()
} else {
stopSidebarResizerCursorStabilizer()
}
}
sidebarResizerCursorStabilizer = timer
timer.resume()
}
private func stopSidebarResizerCursorStabilizer() {
sidebarResizerCursorStabilizer?.cancel()
sidebarResizerCursorStabilizer = nil
}
private func installSidebarResizerPointerMonitorIfNeeded() {
guard sidebarResizerPointerMonitor == nil else { return }
observedWindow?.acceptsMouseMovedEvents = true
sidebarResizerPointerMonitor = NSEvent.addLocalMonitorForEvents(
matching: [
.mouseMoved,
.mouseEntered,
.mouseExited,
.cursorUpdate,
.appKitDefined,
.systemDefined,
.leftMouseDown,
.leftMouseUp,
.leftMouseDragged,
]
) { event in
updateSidebarResizerBandState(using: event)
let shouldOverrideCursorEvent: Bool = {
switch event.type {
case .cursorUpdate, .mouseMoved, .mouseEntered, .mouseExited, .appKitDefined, .systemDefined:
return true
default:
return false
}
}()
if shouldOverrideCursorEvent, (isResizerBandActive || isResizerDragging) {
// Consume hover motion in divider band so overlapped views cannot
// continuously reassert their own cursor while we are resizing.
activateSidebarResizerCursor()
Self.fixedSidebarResizeCursor.set()
return nil
}
return event
}
updateSidebarResizerBandState()
}
private func removeSidebarResizerPointerMonitor() {
if let monitor = sidebarResizerPointerMonitor {
NSEvent.removeMonitor(monitor)
sidebarResizerPointerMonitor = nil
}
isResizerBandActive = false
stopSidebarResizerCursorStabilizer()
scheduleSidebarResizerCursorRelease(force: true)
}
private func sidebarResizerHandleOverlay(
_ handle: SidebarResizerHandle,
width: CGFloat,
accessibilityIdentifier: String? = nil
) -> some View {
Color.clear
.frame(width: width)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.onHover { hovering in
if hovering {
hoveredResizerHandles.insert(handle)
activateSidebarResizerCursor()
} else {
hoveredResizerHandles.remove(handle)
let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left)
if isLeftMouseButtonDown {
// Keep resize cursor pinned through mouse-down so AppKit
// cursorUpdate events from overlapping views do not flash arrow.
activateSidebarResizerCursor()
} else {
// Give mouse-down + drag-start callbacks time to establish state
// before any cursor pop is attempted.
scheduleSidebarResizerCursorRelease(delay: 0.05)
}
}
updateSidebarResizerBandState()
}
.onDisappear {
hoveredResizerHandles.remove(handle)
isResizerDragging = false
sidebarDragStartWidth = nil
isResizerBandActive = false
scheduleSidebarResizerCursorRelease(force: true)
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
sidebarDragStartWidth = sidebarWidth
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
}
activateSidebarResizerCursor()
let startWidth = sidebarDragStartWidth ?? sidebarWidth
let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
.onEnded { _ in
if isResizerDragging {
isResizerDragging = false
sidebarDragStartWidth = nil
}
activateSidebarResizerCursor()
scheduleSidebarResizerCursorRelease()
}
)
.modifier(SidebarResizerAccessibilityModifier(accessibilityIdentifier: accessibilityIdentifier))
}
private var sidebarResizerOverlay: some View {
GeometryReader { proxy in
let totalWidth = max(0, proxy.size.width)
let dividerX = min(max(sidebarWidth, 0), totalWidth)
let leadingWidth = max(0, dividerX - sidebarResizerHitWidthPerSide)
HStack(spacing: 0) {
Color.clear
.frame(width: leadingWidth)
.allowsHitTesting(false)
sidebarResizerHandleOverlay(
.divider,
width: sidebarResizerHitWidthPerSide * 2,
accessibilityIdentifier: "SidebarResizer"
)
Color.clear
.frame(maxWidth: .infinity)
.allowsHitTesting(false)
}
.frame(width: totalWidth, height: proxy.size.height, alignment: .leading)
}
}
private var sidebarView: some View {
VerticalTabsSidebar(
@ -751,64 +1076,6 @@ struct ContentView: View {
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
})
.overlay(alignment: .trailing) {
Color.clear
.frame(width: sidebarHandleWidth)
.contentShape(Rectangle())
.accessibilityIdentifier("SidebarResizer")
.onHover { hovering in
if hovering {
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
} else if isResizerHovering {
if !isResizerDragging {
NSCursor.pop()
isResizerHovering = false
}
}
}
.onDisappear {
if isResizerHovering || isResizerDragging {
NSCursor.pop()
isResizerHovering = false
isResizerDragging = false
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
#if DEBUG
dlog("sidebar.resizeDragStart")
#endif
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
}
let maxSidebarWidth = (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
let nextWidth = max(186, min(maxSidebarWidth, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
.onEnded { _ in
if isResizerDragging {
isResizerDragging = false
if !isResizerHovering {
NSCursor.pop()
}
}
}
)
}
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
@ -998,10 +1265,11 @@ struct ContentView: View {
}
private var contentAndSidebarLayout: AnyView {
let layout: AnyView
if sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue {
// Overlay mode: terminal extends full width, sidebar on top
// This allows withinWindow blur to see the terminal content
return AnyView(
layout = AnyView(
ZStack(alignment: .leading) {
terminalContentWithSidebarDropOverlay
.padding(.leading, sidebarState.isVisible ? sidebarWidth : 0)
@ -1010,16 +1278,26 @@ struct ContentView: View {
}
}
)
} else {
// Standard HStack mode for behindWindow blur
layout = AnyView(
HStack(spacing: 0) {
if sidebarState.isVisible {
sidebarView
}
terminalContentWithSidebarDropOverlay
}
)
}
// Standard HStack mode for behindWindow blur
return AnyView(
HStack(spacing: 0) {
if sidebarState.isVisible {
sidebarView
layout
.overlay(alignment: .leading) {
if sidebarState.isVisible {
sidebarResizerOverlay
.zIndex(1000)
}
}
terminalContentWithSidebarDropOverlay
}
)
}
@ -1041,6 +1319,7 @@ struct ContentView: View {
tabManager.applyWindowBackgroundForSelectedTab()
reconcileMountedWorkspaceIds()
previousSelectedWorkspaceId = tabManager.selectedTabId
installSidebarResizerPointerMonitorIfNeeded()
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
@ -1155,10 +1434,6 @@ struct ContentView: View {
#endif
})
view = AnyView(view.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
sidebarMinX = frame.minX
})
view = AnyView(view.onChange(of: bgGlassTintHex) { _ in
updateWindowGlassTint()
})
@ -1183,8 +1458,20 @@ struct ContentView: View {
AppDelegate.shared?.fullscreenControlsViewModel = nil
})
view = AnyView(view.onChange(of: sidebarWidth) { _ in
updateSidebarResizerBandState()
})
view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in
updateSidebarResizerBandState()
})
view = AnyView(view.ignoresSafeArea())
view = AnyView(view.onDisappear {
removeSidebarResizerPointerMonitor()
})
view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.titlebarAppearsTransparent = true
@ -1198,6 +1485,8 @@ struct ContentView: View {
DispatchQueue.main.async {
observedWindow = window
isFullScreen = window.styleMask.contains(.fullScreen)
installSidebarResizerPointerMonitorIfNeeded()
updateSidebarResizerBandState()
}
}
@ -1414,6 +1703,19 @@ struct ContentView: View {
#endif
}
private struct SidebarResizerAccessibilityModifier: ViewModifier {
let accessibilityIdentifier: String?
@ViewBuilder
func body(content: Content) -> some View {
if let accessibilityIdentifier {
content.accessibilityIdentifier(accessibilityIdentifier)
} else {
content
}
}
}
struct VerticalTabsSidebar: View {
@ObservedObject var updateViewModel: UpdateViewModel
@EnvironmentObject var tabManager: TabManager
@ -2048,14 +2350,6 @@ private struct SidebarTopBlurEffect: NSViewRepresentable {
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
private struct SidebarFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SidebarScrollViewResolver: NSViewRepresentable {
let onResolve: (NSScrollView?) -> Void

View file

@ -1164,15 +1164,13 @@ final class BrowserPanel: Panel, ObservableObject {
navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in
self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false)
}
navDelegate.onDownloadDetected = { [weak self] _ in
self?.beginDownloadActivity()
}
// Set up download delegate for navigation-based downloads.
// Downloads save to a temp file synchronously (no NSSavePanel during WebKit
// callbacks), then show NSSavePanel after the download completes.
let dlDelegate = BrowserDownloadDelegate()
// Download activity is already started at policy-detection time.
dlDelegate.onDownloadStarted = { _ in }
dlDelegate.onDownloadStarted = { [weak self] _ in
self?.beginDownloadActivity()
}
dlDelegate.onDownloadReadyToSave = { [weak self] in
self?.endDownloadActivity()
}
@ -2277,8 +2275,6 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var openInNewTab: ((URL) -> Void)?
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?
/// Called when navigation response policy decides to route to WKDownload.
var onDownloadDetected: ((String?) -> Void)?
/// Direct reference to the download delegate must be set synchronously in didBecome callbacks.
var downloadDelegate: WKDownloadDelegate?
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
@ -2477,7 +2473,6 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
#if DEBUG
dlog("download.policy=download reason=content-disposition mime=\(mime)")
#endif
onDownloadDetected?(response.suggestedFilename)
decisionHandler(.download)
return
}
@ -2488,7 +2483,6 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
#if DEBUG
dlog("download.policy=download reason=cannotShowMIME mime=\(mime)")
#endif
onDownloadDetected?(navigationResponse.response.suggestedFilename)
decisionHandler(.download)
return
}

View file

@ -2633,6 +2633,27 @@ struct WebViewRepresentable: NSViewRepresentable {
super.setFrameSize(newSize)
onGeometryChanged?()
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
return super.hitTest(point)
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else {
return false
}
guard let window, let contentView = window.contentView else {
return false
}
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
}
#if DEBUG

View file

@ -38,6 +38,7 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
/// Map old persisted rawValues to the new enum.
static func migrateMode(_ raw: String) -> SocketControlMode {
@ -55,15 +56,83 @@ struct SocketControlSettings {
return .cmuxOnly
}
static func socketPath() -> String {
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
private static var isDebugBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
return fallback
}
if shouldHonorSocketPathOverride(
environment: environment,
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild
) {
return override
}
#if DEBUG
return "/tmp/cmux-debug.sock"
#else
return fallback
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
return "/tmp/cmux-debug.sock"
}
if isStagingBundleIdentifier(bundleIdentifier) {
return "/tmp/cmux-staging.sock"
}
return "/tmp/cmux.sock"
#endif
}
static func shouldHonorSocketPathOverride(
environment: [String: String],
bundleIdentifier: String?,
isDebugBuild: Bool
) -> Bool {
if isTruthy(environment[allowSocketPathOverrideKey]) {
return true
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
return true
}
return isDebugBuild
}
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.debug"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
}
static func isTruthy(_ raw: String?) -> Bool {
guard let raw else { return false }
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
static func envOverrideEnabled() -> Bool? {

View file

@ -141,6 +141,30 @@ final class NotificationBurstCoalescer {
}
}
struct RecentlyClosedBrowserStack {
private(set) var entries: [ClosedBrowserPanelRestoreSnapshot] = []
let capacity: Int
init(capacity: Int) {
self.capacity = max(1, capacity)
}
var isEmpty: Bool {
entries.isEmpty
}
mutating func push(_ snapshot: ClosedBrowserPanelRestoreSnapshot) {
entries.append(snapshot)
if entries.count > capacity {
entries.removeFirst(entries.count - capacity)
}
}
mutating func pop() -> ClosedBrowserPanelRestoreSnapshot? {
entries.popLast()
}
}
#if DEBUG
// Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably
// catch a single compositor-frame blank flash and any transient compositor scaling (stretched text).
@ -342,6 +366,7 @@ class TabManager: ObservableObject {
}
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@ -406,6 +431,16 @@ class TabManager: ObservableObject {
workspaceCycleCooldownTask?.cancel()
}
private func wireClosedBrowserTracking(for workspace: Workspace) {
workspace.onClosedBrowserPanel = { [weak self] snapshot in
self?.recentlyClosedBrowsers.push(snapshot)
}
}
private func unwireClosedBrowserTracking(for workspace: Workspace) {
workspace.onClosedBrowserPanel = nil
}
var selectedWorkspace: Workspace? {
guard let selectedTabId else { return nil }
return tabs.first(where: { $0.id == selectedTabId })
@ -472,6 +507,7 @@ class TabManager: ObservableObject {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex()
if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newWorkspace, at: insertIndex)
@ -637,6 +673,7 @@ class TabManager: ObservableObject {
guard tabs.count > 1 else { return }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace)
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
@ -658,6 +695,7 @@ class TabManager: ObservableObject {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
lastFocusedPanelByTab.removeValue(forKey: removed.id)
if tabs.isEmpty {
@ -676,6 +714,7 @@ class TabManager: ObservableObject {
/// Attach an existing workspace to this window.
func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) {
wireClosedBrowserTracking(for: workspace)
let insertIndex: Int = {
guard let index else { return tabs.count }
return max(0, min(index, tabs.count))
@ -1558,6 +1597,63 @@ class TabManager: ObservableObject {
return panel?.id
}
/// Reopen the most recently closed browser panel (Cmd+Shift+T).
/// No-op when no browser panel restore snapshot is available.
@discardableResult
func reopenMostRecentlyClosedBrowserPanel() -> Bool {
while let snapshot = recentlyClosedBrowsers.pop() {
guard let targetWorkspace =
tabs.first(where: { $0.id == snapshot.workspaceId })
?? selectedWorkspace
?? tabs.first else {
return false
}
if selectedTabId != targetWorkspace.id {
selectedTabId = targetWorkspace.id
}
if reopenClosedBrowserPanel(snapshot, in: targetWorkspace) {
return true
}
}
return false
}
private func reopenClosedBrowserPanel(
_ snapshot: ClosedBrowserPanelRestoreSnapshot,
in workspace: Workspace
) -> Bool {
if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }),
let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) {
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
let maxIndex = max(0, tabCount - 1)
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
_ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex)
return true
}
if let orientation = snapshot.fallbackSplitOrientation,
let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId,
let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }),
let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first,
let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id),
workspace.newBrowserSplit(
from: anchorPanelId,
orientation: orientation,
insertFirst: snapshot.fallbackSplitInsertFirst,
url: snapshot.url
) != nil {
return true
}
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
return false
}
return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true) != nil
}
/// Flash the currently focused panel so the user can visually confirm focus.
func triggerFocusFlash() {
guard let tab = selectedWorkspace,

View file

@ -2046,7 +2046,7 @@ class TerminalController {
"close_left", "close_right", "close_others",
"new_terminal_right", "new_browser_right",
"reload", "duplicate",
"pin", "unpin", "mark_unread"
"pin", "unpin", "mark_read", "mark_unread"
]
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [
@ -2160,6 +2160,10 @@ class TerminalController {
workspace.setPanelPinned(panelId: surfaceId, pinned: false)
finish(["pinned": false])
case "mark_read", "mark_as_read":
workspace.markPanelRead(surfaceId)
finish()
case "mark_unread", "mark_as_unread":
workspace.markPanelUnread(surfaceId)
finish()

View file

@ -21,11 +21,16 @@ private func portalDebugFrame(_ rect: NSRect) -> String {
final class WindowTerminalHostView: NSView {
override var isOpaque: Bool { false }
private var cachedSidebarDividerX: CGFloat?
#if DEBUG
private var lastDragRouteSignature: String?
#endif
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
@ -60,6 +65,32 @@ final class WindowTerminalHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// The sidebar resizer handle is implemented in SwiftUI. When terminals
// are portal-hosted, this AppKit host can otherwise sit above the handle
// and steal hover/mouse events.
let visibleHostedViews = subviews.compactMap { $0 as? GhosttySurfaceScrollView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// Ignore transient 0-origin hosts while layouts churn (e.g. workspace
// creation/switching). They can temporarily report minX=0 and would
// otherwise clear divider pass-through, causing hover flicker.
let dividerCandidates = visibleHostedViews
.map(\.frame.minX)
.filter { $0 > 1 }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)
@ -178,10 +209,103 @@ final class WindowTerminalHostView: NSView {
#endif
}
private final class SplitDividerOverlayView: NSView {
private struct DividerSegment {
let rect: NSRect
let color: NSColor
}
override var isOpaque: Bool { false }
override var acceptsFirstResponder: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard let window, let rootView = window.contentView else { return }
var dividerSegments: [DividerSegment] = []
collectDividerSegments(in: rootView, into: &dividerSegments)
guard !dividerSegments.isEmpty else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
// Keep separators visible above portal-hosted surfaces while matching each split view's
// native divider color (avoids visible color shifts at tiny pane sizes).
for segment in dividerSegments where segment.rect.intersects(dirtyRect) {
segment.color.setFill()
let rect = segment.rect
let pixelAligned = NSRect(
x: floor(rect.origin.x),
y: floor(rect.origin.y),
width: max(1, round(rect.size.width)),
height: max(1, round(rect.size.height))
)
NSBezierPath(rect: pixelAligned).fill()
}
}
private func collectDividerSegments(in view: NSView, into result: inout [DividerSegment]) {
guard !view.isHidden else { return }
if let splitView = view as? NSSplitView {
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
let dividerColor = overlayDividerColor(for: splitView)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let thickness = max(splitView.dividerThickness, 1)
let dividerRectInSplit: NSRect
if splitView.isVertical {
dividerRectInSplit = NSRect(
x: first.maxX,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
dividerRectInSplit = NSRect(
x: 0,
y: first.maxY,
width: splitView.bounds.width,
height: thickness
)
}
let dividerRectInWindow = splitView.convert(dividerRectInSplit, to: nil)
let dividerRectInOverlay = convert(dividerRectInWindow, from: nil)
if dividerRectInOverlay.intersects(bounds) {
result.append(DividerSegment(rect: dividerRectInOverlay, color: dividerColor))
}
}
}
for subview in view.subviews {
collectDividerSegments(in: subview, into: &result)
}
}
private func overlayDividerColor(for splitView: NSSplitView) -> NSColor {
let divider = splitView.dividerColor.usingColorSpace(.deviceRGB) ?? splitView.dividerColor
let alpha = divider.alphaComponent
guard alpha < 0.999 else { return divider }
guard let bgColor = splitView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)),
let bgRGB = bgColor.usingColorSpace(.deviceRGB) else {
return divider
}
let opaqueBG = bgRGB.withAlphaComponent(1)
let opaqueDivider = divider.withAlphaComponent(1)
return opaqueBG.blended(withFraction: alpha, of: opaqueDivider) ?? divider
}
}
@MainActor
final class WindowTerminalPortal: NSObject {
private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero)
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
@ -202,9 +326,25 @@ final class WindowTerminalPortal: NSObject {
super.init()
hostView.wantsLayer = false
hostView.translatesAutoresizingMaskIntoConstraints = false
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
dividerOverlayView.autoresizingMask = [.width, .height]
_ = ensureInstalled()
}
private func ensureDividerOverlayOnTop() {
if dividerOverlayView.superview !== hostView {
dividerOverlayView.frame = hostView.bounds
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
} else if hostView.subviews.last !== dividerOverlayView {
hostView.addSubview(dividerOverlayView, positioned: .above, relativeTo: nil)
}
if !Self.rectApproximatelyEqual(dividerOverlayView.frame, hostView.bounds) {
dividerOverlayView.frame = hostView.bounds
}
dividerOverlayView.needsDisplay = true
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
@ -239,6 +379,8 @@ final class WindowTerminalPortal: NSObject {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
ensureDividerOverlayOnTop()
return true
}
@ -394,6 +536,8 @@ final class WindowTerminalPortal: NSObject {
hostView.addSubview(hostedView, positioned: .above, relativeTo: nil)
}
ensureDividerOverlayOnTop()
synchronizeHostedView(withId: hostedId)
pruneDeadEntries()
}
@ -523,6 +667,8 @@ final class WindowTerminalPortal: NSObject {
#endif
hostedView.isHidden = shouldHide
}
ensureDividerOverlayOnTop()
}
private func pruneDeadEntries() {

View file

@ -194,6 +194,16 @@ enum SidebarBranchOrdering {
}
}
struct ClosedBrowserPanelRestoreSnapshot {
let workspaceId: UUID
let url: URL?
let originalPaneId: UUID
let originalTabIndex: Int
let fallbackSplitOrientation: SplitOrientation?
let fallbackSplitInsertFirst: Bool
let fallbackAnchorPaneId: UUID?
}
/// Workspace represents a sidebar tab.
/// Each workspace contains one BonsplitController that manages split panes and nested surfaces.
@MainActor
@ -219,6 +229,9 @@ final class Workspace: Identifiable, ObservableObject {
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
// layout/size synchronization.
@ -247,6 +260,9 @@ final class Workspace: Identifiable, ObservableObject {
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
@Published private(set) var pinnedPanelIds: Set<UUID> = []
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
private var manualUnreadMarkedAt: [UUID: Date] = [:]
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@ -415,6 +431,7 @@ final class Workspace: Identifiable, ObservableObject {
/// Panel IDs that were in a pane when a pane-close operation was approved.
/// Bonsplit pane-close does not emit per-tab didClose callbacks.
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
private var isApplyingTabSelection = false
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
private var isReconcilingFocusState = false
@ -535,7 +552,10 @@ final class Workspace: Identifiable, ObservableObject {
private func syncUnreadBadgeStateForPanel(_ panelId: UUID) {
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
let shouldShowUnread = manualUnreadPanelIds.contains(panelId) || hasUnreadNotification(panelId: panelId)
let shouldShowUnread = Self.shouldShowUnreadIndicator(
hasUnreadNotification: hasUnreadNotification(panelId: panelId),
isManuallyUnread: manualUnreadPanelIds.contains(panelId)
)
if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread {
return
}
@ -628,14 +648,45 @@ final class Workspace: Identifiable, ObservableObject {
func markPanelUnread(_ panelId: UUID) {
guard panels[panelId] != nil else { return }
guard manualUnreadPanelIds.insert(panelId).inserted else { return }
manualUnreadMarkedAt[panelId] = Date()
syncUnreadBadgeStateForPanel(panelId)
}
func markPanelRead(_ panelId: UUID) {
guard panels[panelId] != nil else { return }
AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId)
clearManualUnread(panelId: panelId)
}
func clearManualUnread(panelId: UUID) {
guard manualUnreadPanelIds.remove(panelId) != nil else { return }
let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil
manualUnreadMarkedAt.removeValue(forKey: panelId)
guard didRemoveUnread else { return }
syncUnreadBadgeStateForPanel(panelId)
}
static func shouldClearManualUnread(
previousFocusedPanelId: UUID?,
nextFocusedPanelId: UUID,
isManuallyUnread: Bool,
markedAt: Date?,
now: Date = Date(),
sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval
) -> Bool {
guard isManuallyUnread else { return false }
if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId {
return true
}
guard let markedAt else { return true }
return now.timeIntervalSince(markedAt) >= sameTabGraceInterval
}
static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool {
hasUnreadNotification || isManuallyUnread
}
// MARK: - Title Management
var hasCustomTitle: Bool {
@ -737,6 +788,7 @@ final class Workspace: Identifiable, ObservableObject {
pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) }
manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) }
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
@ -1172,6 +1224,115 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private struct BrowserCloseFallbackPlan {
let orientation: SplitOrientation
let insertFirst: Bool
let anchorPaneId: UUID?
}
private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) {
guard let panelId = panelIdFromSurfaceId(tab.id),
let browserPanel = browserPanel(for: panelId),
let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else {
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id)
return
}
let fallbackPlan = browserCloseFallbackPlan(
forPaneId: pane.id.uuidString,
in: bonsplitController.treeSnapshot()
)
let resolvedURL = browserPanel.currentURL
?? browserPanel.webView.url
?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:))
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
workspaceId: id,
url: resolvedURL,
originalPaneId: pane.id,
originalTabIndex: tabIndex,
fallbackSplitOrientation: fallbackPlan?.orientation,
fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false,
fallbackAnchorPaneId: fallbackPlan?.anchorPaneId
)
}
private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) {
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
}
private func browserCloseFallbackPlan(
forPaneId targetPaneId: String,
in node: ExternalTreeNode
) -> BrowserCloseFallbackPlan? {
switch node {
case .pane:
return nil
case .split(let splitNode):
if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId {
return BrowserCloseFallbackPlan(
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
insertFirst: true,
anchorPaneId: browserNearestPaneId(
in: splitNode.second,
targetCenter: browserPaneCenter(firstPane)
)
)
}
if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId {
return BrowserCloseFallbackPlan(
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
insertFirst: false,
anchorPaneId: browserNearestPaneId(
in: splitNode.first,
targetCenter: browserPaneCenter(secondPane)
)
)
}
if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) {
return nested
}
return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second)
}
}
private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) {
(
x: pane.frame.x + (pane.frame.width * 0.5),
y: pane.frame.y + (pane.frame.height * 0.5)
)
}
private func browserNearestPaneId(
in node: ExternalTreeNode,
targetCenter: (x: Double, y: Double)?
) -> UUID? {
var panes: [ExternalPaneNode] = []
browserCollectPaneNodes(node: node, into: &panes)
guard !panes.isEmpty else { return nil }
let bestPane: ExternalPaneNode?
if let targetCenter {
bestPane = panes.min { lhs, rhs in
let lhsCenter = browserPaneCenter(lhs)
let rhsCenter = browserPaneCenter(rhs)
let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2)
let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2)
if lhsDistance != rhsDistance {
return lhsDistance < rhsDistance
}
return lhs.id < rhs.id
}
} else {
bestPane = panes.first
}
guard let bestPane else { return nil }
return UUID(uuidString: bestPane.id)
}
@discardableResult
func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool {
guard let tabId = surfaceIdFromPanelId(panelId) else { return false }
@ -1253,8 +1414,10 @@ final class Workspace: Identifiable, ObservableObject {
}
if detached.manuallyUnread {
manualUnreadPanelIds.insert(detached.panelId)
manualUnreadMarkedAt[detached.panelId] = .distantPast
} else {
manualUnreadPanelIds.remove(detached.panelId)
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
}
guard let newTabId = bonsplitController.createTab(
@ -1274,6 +1437,7 @@ final class Workspace: Identifiable, ObservableObject {
panelCustomTitles.removeValue(forKey: detached.panelId)
pinnedPanelIds.remove(detached.panelId)
manualUnreadPanelIds.remove(detached.panelId)
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
panelSubscriptions.removeValue(forKey: detached.panelId)
return nil
}
@ -1711,6 +1875,7 @@ extension Workspace: BonsplitDelegate {
}
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
let previousFocusedPanelId = focusedPanelId
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
bonsplitController.focusPane(pane)
@ -1750,7 +1915,24 @@ extension Workspace: BonsplitDelegate {
}
panel.focus()
clearManualUnread(panelId: panelId)
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
let markedAt = manualUnreadMarkedAt[panelId]
if Self.shouldClearManualUnread(
previousFocusedPanelId: previousFocusedPanelId,
nextFocusedPanelId: panelId,
isManuallyUnread: isManuallyUnread,
markedAt: markedAt
) {
triggerFocusFlash(panelId: panelId)
let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash
if clearDelay <= 0 {
clearManualUnread(panelId: panelId)
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in
self?.clearManualUnread(panelId: panelId)
}
}
}
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
@ -1797,12 +1979,14 @@ extension Workspace: BonsplitDelegate {
}
if forceCloseTabIds.contains(tab.id) {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
return true
}
if let panelId = panelIdFromSurfaceId(tab.id),
pinnedPanelIds.contains(panelId) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
NSSound.beep()
return false
}
@ -1810,6 +1994,7 @@ extension Workspace: BonsplitDelegate {
// Check if the panel needs close confirmation
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
return true
}
@ -1818,6 +2003,7 @@ extension Workspace: BonsplitDelegate {
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
// this gating on the second pass.
if terminalPanel.needsConfirmClose() {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
if pendingCloseConfirmTabIds.contains(tab.id) {
return false
}
@ -1843,6 +2029,7 @@ extension Workspace: BonsplitDelegate {
return false
}
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
recordPostCloseSelection()
return true
}
@ -1850,6 +2037,7 @@ extension Workspace: BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) {
forceCloseTabIds.remove(tabId)
let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId)
let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
// Clean up our panel
guard let panelId = panelIdFromSurfaceId(tabId) else {
@ -1885,6 +2073,9 @@ extension Workspace: BonsplitDelegate {
manuallyUnread: manualUnreadPanelIds.contains(panelId)
)
} else {
if let closedBrowserRestoreSnapshot {
onClosedBrowserPanel?(closedBrowserRestoreSnapshot)
}
panel?.close()
}
@ -1896,6 +2087,7 @@ extension Workspace: BonsplitDelegate {
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)

View file

@ -42,7 +42,10 @@ struct WorkspaceContentView: View {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,

View file

@ -363,6 +363,11 @@ struct cmuxApp: App {
closeTabOrWindow()
}
.keyboardShortcut("w", modifiers: [.command, .shift])
Button("Reopen Closed Browser Panel") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
}
// Find
@ -2570,7 +2575,7 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardNote("Controls access to the local Unix socket for programmatic control. In \"cmux processes only\" mode, only processes spawned inside cmux terminals can connect.")
SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH (set CMUX_ALLOW_SOCKET_OVERRIDE=1 for stable/nightly builds).")
}
SettingsCard {

View file

@ -271,6 +271,44 @@ final class NotificationBurstCoalescerTests: XCTestCase {
}
}
final class RecentlyClosedBrowserStackTests: XCTestCase {
func testPopReturnsEntriesInLIFOOrder() {
var stack = RecentlyClosedBrowserStack(capacity: 20)
stack.push(makeSnapshot(index: 1))
stack.push(makeSnapshot(index: 2))
stack.push(makeSnapshot(index: 3))
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
XCTAssertNil(stack.pop())
}
func testPushDropsOldestEntriesWhenCapacityExceeded() {
var stack = RecentlyClosedBrowserStack(capacity: 3)
for index in 1...5 {
stack.push(makeSnapshot(index: index))
}
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertNil(stack.pop())
}
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
fallbackSplitInsertFirst: false,
fallbackAnchorPaneId: UUID()
)
}
}
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
let projectRoot = findProjectRoot()
@ -316,3 +354,85 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase {
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
final class SocketControlSettingsTests: XCTestCase {
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux.sock")
}
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
}
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
}
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
}
func testStableReleaseCanOptInToSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
}
func testDefaultSocketPathByChannel() {
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
"/tmp/cmux.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
"/tmp/cmux-nightly.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
"/tmp/cmux-debug.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
"/tmp/cmux-staging.sock"
)
}
}

View file

@ -0,0 +1,108 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class WorkspaceManualUnreadTests: XCTestCase {
func testShouldClearManualUnreadWhenFocusMovesToDifferentPanel() {
let previousFocusedPanelId = UUID()
let nextFocusedPanelId = UUID()
XCTAssertTrue(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: previousFocusedPanelId,
nextFocusedPanelId: nextFocusedPanelId,
isManuallyUnread: true,
markedAt: Date()
)
)
}
func testShouldNotClearManualUnreadWhenFocusStaysOnSamePanelWithinGrace() {
let panelId = UUID()
let now = Date()
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: panelId,
nextFocusedPanelId: panelId,
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.05),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldClearManualUnreadWhenFocusStaysOnSamePanelAfterGrace() {
let panelId = UUID()
let now = Date()
XCTAssertTrue(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: panelId,
nextFocusedPanelId: panelId,
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.25),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldNotClearManualUnreadWhenNotManuallyUnread() {
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: UUID(),
nextFocusedPanelId: UUID(),
isManuallyUnread: false,
markedAt: Date()
)
)
}
func testShouldNotClearManualUnreadWhenNoPreviousFocusAndWithinGrace() {
let now = Date()
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: nil,
nextFocusedPanelId: UUID(),
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.05),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldShowUnreadIndicatorWhenNotificationIsUnread() {
XCTAssertTrue(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: true,
isManuallyUnread: false
)
)
}
func testShouldShowUnreadIndicatorWhenManualUnreadIsSet() {
XCTAssertTrue(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: false,
isManuallyUnread: true
)
)
}
func testShouldHideUnreadIndicatorWhenNeitherNotificationNorManualUnreadExists() {
XCTAssertFalse(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: false,
isManuallyUnread: false
)
)
}
}

View file

@ -146,6 +146,10 @@ def main() -> int:
by_tab_only = c._call("tab.action", {"tab_id": tab_ref, "action": "mark_unread"}) or {}
_must(str(by_tab_only.get("tab_ref") or "").startswith("tab:"), f"Expected tab_ref in tab_id-only result: {by_tab_only}")
_must(str(by_tab_only.get("workspace_id") or "") == ws_id, f"tab_id-only action should resolve target workspace: {by_tab_only}")
mark_read = c._call("tab.action", {"tab_id": tab_ref, "action": "mark_read"}) or {}
_must(str(mark_read.get("tab_ref") or "").startswith("tab:"), f"Expected tab_ref in mark_read result: {mark_read}")
_must(str(mark_read.get("workspace_id") or "") == ws_id, f"mark_read should resolve target workspace: {mark_read}")
finally:
if ws_other:
try: