Merge origin/main into feat-sidebar-branch-refresh-on-close
This commit is contained in:
commit
a2c39802d1
15 changed files with 1189 additions and 105 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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..<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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
cmuxTests/WorkspaceManualUnreadTests.swift
Normal file
108
cmuxTests/WorkspaceManualUnreadTests.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue