diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1873e352..88cab6e8 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3031,7 +3031,7 @@ struct CMUXCLI { new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-unread + mark-read | mark-unread Flags: --action Action name (required if not positional) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 4578fdcc..86fead61 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -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) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 00d34d8b..837b366a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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 = [] @State private var isResizerDragging = false - private let sidebarHandleWidth: CGFloat = 6 + @State private var sidebarDragStartWidth: CGFloat? @State private var selectedTabIds: Set = [] @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 diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f6dc10d4..118a2fab 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index bda555bf..0578ef50 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -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 diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 67b91682..c0fb35bb 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -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? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 62d7b1d2..016765cd 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0526d593..baf98b29 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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() diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index af6b0d72..a4d37f7c 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -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: ÷rSegments) + 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.. 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() { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index be5abe79..5aab0068 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -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 = [] @Published private(set) var manualUnreadPanelIds: Set = [] + 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) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ec34dd1b..defce523 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -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, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0ae4a899..48ec051c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index e2978e55..cd81e89e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -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" + ) + } +} diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift new file mode 100644 index 00000000..d5464d73 --- /dev/null +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -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 + ) + ) + } +} diff --git a/tests_v2/test_tab_workspace_action_naming.py b/tests_v2/test_tab_workspace_action_naming.py index 6b3f4805..c792b92e 100644 --- a/tests_v2/test_tab_workspace_action_naming.py +++ b/tests_v2/test_tab_workspace_action_naming.py @@ -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: