import AppKit import Bonsplit import SwiftUI import ObjectiveC import UniformTypeIdentifiers import WebKit struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 var body: some View { Capsule(style: .continuous) .fill(.regularMaterial) .overlay( Capsule(style: .continuous) .stroke(Color.white.opacity(0.30 * emphasis), lineWidth: 0.8) ) .shadow(color: Color.black.opacity(0.22 * emphasis), radius: 2, x: 0, y: 1) } } /// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView enum WindowGlassEffect { private static var glassViewKey: UInt8 = 0 private static var tintOverlayKey: UInt8 = 0 static var isAvailable: Bool { NSClassFromString("NSGlassEffectView") != nil } static func apply(to window: NSWindow, tintColor: NSColor? = nil) { guard let originalContentView = window.contentView else { return } // Check if we already applied glass (avoid re-wrapping) if let existingGlass = objc_getAssociatedObject(window, &glassViewKey) as? NSView { // Already applied, just update the tint updateTint(on: existingGlass, color: tintColor, window: window) return } let bounds = originalContentView.bounds // Create the glass/blur view let glassView: NSVisualEffectView let usingGlassEffectView: Bool // Try NSGlassEffectView first (macOS 26 Tahoe+) if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type { usingGlassEffectView = true glassView = glassClass.init(frame: bounds) glassView.wantsLayer = true glassView.layer?.cornerRadius = 0 // Apply tint color via private API if let color = tintColor { let selector = NSSelectorFromString("setTintColor:") if glassView.responds(to: selector) { glassView.perform(selector, with: color) } } } else { usingGlassEffectView = false // Fallback to NSVisualEffectView glassView = NSVisualEffectView(frame: bounds) glassView.blendingMode = .behindWindow // Favor a lighter fallback so behind-window glass reads more transparent. glassView.material = .underWindowBackground glassView.state = .active glassView.wantsLayer = true } glassView.autoresizingMask = [.width, .height] if usingGlassEffectView { // NSGlassEffectView is a full replacement for the contentView. window.contentView = glassView // Re-add the original SwiftUI hosting view on top of the glass, filling entire area. originalContentView.translatesAutoresizingMaskIntoConstraints = false originalContentView.wantsLayer = true originalContentView.layer?.backgroundColor = NSColor.clear.cgColor glassView.addSubview(originalContentView) NSLayoutConstraint.activate([ originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor), originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) ]) } else { // For NSVisualEffectView fallback (macOS 13-15), do NOT replace window.contentView. // Replacing contentView can break traffic light rendering with // `.fullSizeContentView` + `titlebarAppearsTransparent`. glassView.translatesAutoresizingMaskIntoConstraints = false originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil) NSLayoutConstraint.activate([ glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor), glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor), glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor), glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor) ]) } // Add tint overlay between glass and content (for fallback) if let tintColor, !usingGlassEffectView { let tintOverlay = NSView(frame: bounds) tintOverlay.translatesAutoresizingMaskIntoConstraints = false tintOverlay.wantsLayer = true tintOverlay.layer?.backgroundColor = tintColor.cgColor glassView.addSubview(tintOverlay) NSLayoutConstraint.activate([ tintOverlay.topAnchor.constraint(equalTo: glassView.topAnchor), tintOverlay.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), tintOverlay.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), tintOverlay.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) ]) objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) } // Store reference objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) } /// Update the tint color on an existing glass effect static func updateTint(to window: NSWindow, color: NSColor?) { guard let glassView = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { return } updateTint(on: glassView, color: color, window: window) } private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) { // For NSGlassEffectView, use setTintColor: if glassView.className == "NSGlassEffectView" { let selector = NSSelectorFromString("setTintColor:") if glassView.responds(to: selector) { glassView.perform(selector, with: color) } } else { // For NSVisualEffectView fallback, update the tint overlay if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView { tintOverlay.layer?.backgroundColor = color?.cgColor } } } static func remove(from window: NSWindow) { // Note: Removing would require restoring original contentView structure // For now, just clear the reference objc_setAssociatedObject(window, &glassViewKey, nil, .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(window, &tintOverlayKey, nil, .OBJC_ASSOCIATION_RETAIN) } } final class SidebarState: ObservableObject { @Published var isVisible: Bool = true func toggle() { isVisible.toggle() } } 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 { static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { guard let pasteboardTypes else { return false } return pasteboardTypes.contains(bonsplitTabTransferType) } static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { guard let pasteboardTypes else { return false } return pasteboardTypes.contains(sidebarTabReorderType) } static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { guard let pasteboardTypes else { return false } return pasteboardTypes.contains(.fileURL) } static func shouldCaptureFileDropDestination( pasteboardTypes: [NSPasteboard.PasteboardType]?, hasLocalDraggingSource: Bool ) -> Bool { // Local file drags (e.g. in-app draggable folder views) are valid drop // inputs; rely on explicit non-file drag types below to avoid hijacking // Bonsplit/sidebar drags. _ = hasLocalDraggingSource guard hasFileURL(pasteboardTypes) else { return false } // Prefer explicit non-file drag types so stale fileURL entries cannot hijack // Bonsplit tab drags or sidebar tab reorder drags. if hasBonsplitTabTransfer(pasteboardTypes) { return false } if hasSidebarTabReorder(pasteboardTypes) { return false } return true } static func shouldCaptureFileDropDestination( pasteboardTypes: [NSPasteboard.PasteboardType]? ) -> Bool { shouldCaptureFileDropDestination( pasteboardTypes: pasteboardTypes, hasLocalDraggingSource: false ) } static func shouldCaptureFileDropOverlay( pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) -> Bool { guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } guard isDragMouseEvent(eventType) else { return false } return true } static func shouldCaptureSidebarExternalOverlay( hasSidebarDragState: Bool, pasteboardTypes: [NSPasteboard.PasteboardType]? ) -> Bool { guard hasSidebarDragState else { return false } return hasSidebarTabReorder(pasteboardTypes) } static func shouldCaptureSidebarExternalOverlay( draggedTabId: UUID?, pasteboardTypes: [NSPasteboard.PasteboardType]? ) -> Bool { shouldCaptureSidebarExternalOverlay( hasSidebarDragState: draggedTabId != nil, pasteboardTypes: pasteboardTypes ) } static func shouldPassThroughPortalHitTesting( pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType? ) -> Bool { guard isPortalDragEvent(eventType) else { return false } return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) } private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { eventType == .leftMouseDragged || eventType == .rightMouseDragged || eventType == .otherMouseDragged } private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { // Restrict portal pass-through to explicit drag-motion events so stale // NSPasteboard(name: .drag) types cannot hijack normal pointer input. guard let eventType else { return false } switch eventType { case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: return true default: return false } } } /// Transparent NSView installed on the window's theme frame (above the NSHostingView) to /// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's /// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply /// embedded terminal views. This overlay sits above the entire content view hierarchy and /// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. /// /// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, /// scrolls, and other interactions pass through normally. 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? private var lastHitTestLogSignature: String? private var lastDragRouteLogSignatureByPhase: [String: String] = [:] override var acceptsFirstResponder: Bool { false } override init(frame frameRect: NSRect) { super.init(frame: frameRect) registerForDraggedTypes([.fileURL]) } 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. override func hitTest(_ point: NSPoint) -> NSView? { let pb = NSPasteboard(name: .drag) let eventType = NSApp.currentEvent?.type let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( pasteboardTypes: pb.types, eventType: eventType ) #if DEBUG logHitTestDecision( pasteboardTypes: pb.types, eventType: eventType, shouldCapture: shouldCapture ) #endif guard shouldCapture else { return nil } return super.hitTest(point) } // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard // data causes hitTest to return self when no drag is actually active. // We hit-test contentView directly and dispatch to the target rather than using // window.sendEvent(), which caches the mouse target and causes infinite recursion. private func forwardEvent(_ event: NSEvent) { guard !isForwardingMouseEvent else { return } guard let window, let contentView = window.contentView else { return } let eventButton = dragButton(for: event) isForwardingMouseEvent = true isHidden = true defer { isHidden = false isForwardingMouseEvent = false } 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) case .leftMouseUp: target.mouseUp(with: event) case .leftMouseDragged: target.mouseDragged(with: event) case .rightMouseDown: target.rightMouseDown(with: event) case .rightMouseUp: target.rightMouseUp(with: event) case .rightMouseDragged: target.rightMouseDragged(with: event) case .otherMouseDown: target.otherMouseDown(with: event) case .otherMouseUp: target.otherMouseUp(with: event) case .otherMouseDragged: target.otherMouseDragged(with: event) 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) } override func mouseUp(with event: NSEvent) { forwardEvent(event) } override func mouseDragged(with event: NSEvent) { forwardEvent(event) } override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } override func scrollWheel(with event: NSEvent) { forwardEvent(event) } // MARK: NSDraggingDestination – accept file drops over terminal and browser views. // // AppKit sends draggingEntered once when the drag enters this overlay, then // draggingUpdated as the cursor moves within it. We track which WKWebView (if // any) is under the cursor and synthesize enter/exit calls so the browser's // HTML5 drag events (dragenter, dragleave, drop) fire correctly. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { return updateDragTarget(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { return updateDragTarget(sender, phase: "updated") } override func draggingExited(_ sender: (any NSDraggingInfo)?) { if let prev = activeDragWebView { prev.draggingExited(sender) activeDragWebView = nil } } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: types, hasLocalDraggingSource: hasLocalDraggingSource ) let webView = activeDragWebView activeDragWebView = nil let terminal = terminalUnderPoint(sender.draggingLocation) let hasTerminalTarget = terminal != nil #if DEBUG logDragRouteDecision( phase: "perform", pasteboardTypes: types, shouldCapture: shouldCapture, hasLocalDraggingSource: hasLocalDraggingSource, hasTerminalTarget: hasTerminalTarget ) #endif guard shouldCapture else { return false } if let webView { return webView.performDragOperation(sender) } guard let terminal else { return false } return terminal.performDragOperation(sender) } private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { let loc = sender.draggingLocation let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: types, hasLocalDraggingSource: hasLocalDraggingSource ) let webView = shouldCapture ? webViewUnderPoint(loc) : nil if let prev = activeDragWebView, prev !== webView { prev.draggingExited(sender) activeDragWebView = nil } if let webView { if activeDragWebView !== webView { activeDragWebView = webView return webView.draggingEntered(sender) } return webView.draggingUpdated(sender) } let hasTerminalTarget = terminalUnderPoint(loc) != nil #if DEBUG logDragRouteDecision( phase: phase, pasteboardTypes: types, shouldCapture: shouldCapture, hasLocalDraggingSource: hasLocalDraggingSource, hasTerminalTarget: hasTerminalTarget ) #endif guard shouldCapture, hasTerminalTarget else { return [] } return .copy } private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { guard let types, !types.isEmpty else { return "-" } return types.map(\.rawValue).joined(separator: ",") } /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { guard let window, let contentView = window.contentView else { return nil } isHidden = true defer { isHidden = false } let point = contentView.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) var current: NSView? = hitView while let view = current { if let webView = view as? WKWebView { return webView } current = view.superview } return nil } private func debugTopHitViewForCurrentEvent() -> String { guard let window, let currentEvent = NSApp.currentEvent, let contentView = window.contentView, let themeFrame = contentView.superview else { return "-" } let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) isHidden = true defer { isHidden = false } guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } var chain: [String] = [] var current: NSView? = hit var depth = 0 while let view = current, depth < 6 { chain.append(debugHitViewDescriptor(view)) current = view.superview depth += 1 } return chain.joined(separator: "->") } private func debugHitViewDescriptor(_ view: NSView) -> String { let className = String(describing: type(of: view)) let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) let dragTypes = debugRegisteredDragTypes(view) return "\(className)@\(ptr){dragTypes=\(dragTypes)}" } private func debugRegisteredDragTypes(_ view: NSView) -> String { let types = view.registeredDraggedTypes guard !types.isEmpty else { return "-" } let interestingTypes = types.filter { type in let raw = type.rawValue return raw == NSPasteboard.PasteboardType.fileURL.rawValue || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue || raw.contains("public.text") || raw.contains("public.url") || raw.contains("public.data") } let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes let rendered = selected.map(\.rawValue).joined(separator: ",") if selected.count < types.count { return "\(rendered),+\(types.count - selected.count)" } return rendered } private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { guard let types else { return false } return types.contains(.fileURL) || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) } private func debugEventName(_ eventType: NSEvent.EventType?) -> String { guard let eventType else { return "none" } switch eventType { case .cursorUpdate: return "cursorUpdate" case .appKitDefined: return "appKitDefined" case .systemDefined: return "systemDefined" case .applicationDefined: return "applicationDefined" case .periodic: return "periodic" case .mouseMoved: return "mouseMoved" case .mouseEntered: return "mouseEntered" case .mouseExited: return "mouseExited" case .flagsChanged: return "flagsChanged" case .leftMouseDown: return "leftMouseDown" case .leftMouseUp: return "leftMouseUp" case .leftMouseDragged: return "leftMouseDragged" case .rightMouseDown: return "rightMouseDown" case .rightMouseUp: return "rightMouseUp" case .rightMouseDragged: return "rightMouseDragged" case .otherMouseDown: return "otherMouseDown" case .otherMouseUp: return "otherMouseUp" case .otherMouseDragged: return "otherMouseDragged" case .scrollWheel: return "scrollWheel" default: return "other(\(eventType.rawValue))" } } #if DEBUG private func logHitTestDecision( pasteboardTypes: [NSPasteboard.PasteboardType]?, eventType: NSEvent.EventType?, shouldCapture: Bool ) { let isDragEvent = eventType == .leftMouseDragged || eventType == .rightMouseDragged || eventType == .otherMouseDragged guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" guard lastHitTestLogSignature != signature else { return } lastHitTestLogSignature = signature dlog( "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + "event=\(debugEventName(eventType)) " + "topHit=\(debugTopHitViewForCurrentEvent()) " + "types=\(debugPasteboardTypes(pasteboardTypes))" ) } private func logDragRouteDecision( phase: String, pasteboardTypes: [NSPasteboard.PasteboardType]?, shouldCapture: Bool, hasLocalDraggingSource: Bool, hasTerminalTarget: Bool ) { guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } let signature = [ shouldCapture ? "1" : "0", hasLocalDraggingSource ? "1" : "0", hasTerminalTarget ? "1" : "0", debugPasteboardTypes(pasteboardTypes) ].joined(separator: "|") guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } lastDragRouteLogSignatureByPhase[phase] = signature dlog( "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + "types=\(debugPasteboardTypes(pasteboardTypes))" ) } #endif /// Hit-tests the window to find the GhosttyNSView under the cursor. func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { if let window, let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { return portalTerminal } guard let window, let contentView = window.contentView else { return nil } isHidden = true defer { isHidden = false } let point = contentView.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) var current: NSView? = hitView while let view = current { if let terminal = view as? GhosttyNSView { return terminal } current = view.superview } return nil } } var fileDropOverlayKey: UInt8 = 0 enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 // During workspace cycling, keep only a minimal handoff pair (selected + retiring). static let maxMountedWorkspacesDuringCycle = 2 static func nextMountedWorkspaceIds( current: [UUID], selected: UUID?, pinnedIds: Set, orderedTabIds: [UUID], isCycleHot: Bool, maxMounted: Int ) -> [UUID] { let existing = Set(orderedTabIds) let clampedMax = max(1, maxMounted) var ordered = current.filter { existing.contains($0) } if let selected, existing.contains(selected) { ordered.removeAll { $0 == selected } ordered.insert(selected, at: 0) } if isCycleHot, let selected { let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds) for id in warmIds.reversed() { ordered.removeAll { $0 == id } ordered.insert(id, at: 0) } } if isCycleHot, pinnedIds.isEmpty, let selected { ordered.removeAll { $0 != selected } } // Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority. // This runs after warming to prevent neighbor warming from evicting the retiring workspace. let prioritizedPinnedIds = pinnedIds .filter { existing.contains($0) && $0 != selected } .sorted { lhs, rhs in let lhsIndex = orderedTabIds.firstIndex(of: lhs) ?? .max let rhsIndex = orderedTabIds.firstIndex(of: rhs) ?? .max return lhsIndex < rhsIndex } if let selected, existing.contains(selected) { ordered.removeAll { $0 == selected } ordered.insert(selected, at: 0) } var pinnedInsertionIndex = (selected != nil) ? 1 : 0 for pinnedId in prioritizedPinnedIds { ordered.removeAll { $0 == pinnedId } let insertionIndex = min(pinnedInsertionIndex, ordered.count) ordered.insert(pinnedId, at: insertionIndex) pinnedInsertionIndex += 1 } if ordered.count > clampedMax { ordered.removeSubrange(clampedMax...) } return ordered } private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] { guard orderedTabIds.contains(selected) else { return [selected] } // Keep warming focused to the selected workspace. Retiring/target workspaces are // pinned by handoff logic, so warming adjacent neighbors here just adds layout work. return [selected] } } /// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, let contentView = window.contentView, let themeFrame = contentView.superview else { return } let overlay = FileDropOverlayView(frame: contentView.frame) overlay.translatesAutoresizingMaskIntoConstraints = false overlay.onDrop = { [weak tabManager] urls in MainActor.assumeIsolated { guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false } return terminal.hostedView.handleDroppedURLs(urls) } } themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView) NSLayoutConstraint.activate([ overlay.topAnchor.constraint(equalTo: contentView.topAnchor), overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) ]) objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN) } struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel let windowId: UUID @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @State private var sidebarDragStartWidth: CGFloat? @State private var selectedTabIds: Set = [] @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @State private var titlebarText: String = "" @State private var isFullScreen: Bool = false @State private var observedWindow: NSWindow? @StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel() @State private var previousSelectedWorkspaceId: UUID? @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var titlebarThemeGeneration: UInt64 = 0 @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( updateViewModel: updateViewModel, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. @State private var titlebarPadding: CGFloat = 32 private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) } let selectedWorkspaceId = tabManager.selectedTabId let retiringWorkspaceId = self.retiringWorkspaceId return ZStack { ZStack { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id let isInputActive = isSelectedWorkspace || isRetiringWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, workspacePortalPriority: portalPriority ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) } } .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .tabs) NotificationsPage(selection: $sidebarSelectionState.selection) .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .notifications) } .padding(.top, titlebarPadding) .overlay(alignment: .top) { // Titlebar overlay is only over terminal content, not the sidebar. customTitlebar } } private var terminalContentWithSidebarDropOverlay: some View { terminalContent .overlay { SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) } } @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true @AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0 @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } private var fakeTitlebarBackground: Color { _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor let configuredOpacity = CGFloat(max(0, min(1, GhosttyApp.shared.defaultBackgroundOpacity))) let minimumChromeOpacity: CGFloat = ghosttyBackground.isLightColor ? 0.90 : 0.84 let chromeOpacity = max(minimumChromeOpacity, configuredOpacity) return Color(nsColor: ghosttyBackground.withAlphaComponent(chromeOpacity)) } private var fakeTitlebarTextColor: Color { _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor return ghosttyBackground.isLightColor ? Color.black.opacity(0.78) : Color.white.opacity(0.82) } private var fakeTitlebarSeparatorColor: Color { _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor return ghosttyBackground.isLightColor ? Color.black.opacity(0.18) : Color.white.opacity(0.22) } private var fullscreenControls: some View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, anchorView: fullscreenControlsViewModel.notificationsAnchorView ) }, onNewTab: { tabManager.addTab() } ) } private var customTitlebar: some View { ZStack { // Enable window dragging from the titlebar strip without making the entire content // view draggable (which breaks drag gestures like tab reordering). WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { fullscreenControls } // Draggable folder icon + focused command name if let directory = focusedDirectory { DraggableFolderIcon(directory: directory) } Text(titlebarText) .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) Spacer() } .frame(height: 28) .padding(.top, 2) .padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset + CGFloat(debugTitlebarLeadingExtra))) .padding(.trailing, 8) } .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) .onTapGesture(count: 2) { NSApp.keyWindow?.zoom(nil) } .background(fakeTitlebarBackground) .overlay(alignment: .bottom) { Rectangle() .fill(fakeTitlebarSeparatorColor) .frame(height: 1) } } private func updateTitlebarText() { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { if !titlebarText.isEmpty { titlebarText = "" } return } let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) if titlebarText != title { titlebarText = title } } private func scheduleTitlebarTextRefresh() { titlebarTextUpdateCoalescer.signal { updateTitlebarText() } } private func scheduleTitlebarThemeRefresh() { titlebarThemeUpdateCoalescer.signal { titlebarThemeGeneration &+= 1 } } private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { return nil } // Use focused panel's directory if available if let focusedPanelId = tab.focusedPanelId, let panelDir = tab.panelDirectories[focusedPanelId] { let trimmed = panelDir.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) return dir.isEmpty ? nil : dir } 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 layout = AnyView( ZStack(alignment: .leading) { terminalContentWithSidebarDropOverlay .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView } } ) } else { // Standard HStack mode for behindWindow blur layout = AnyView( HStack(spacing: 0) { if sidebarState.isVisible { sidebarView } terminalContentWithSidebarDropOverlay } ) } return AnyView( layout .overlay(alignment: .leading) { if sidebarState.isVisible { sidebarResizerOverlay .zIndex(1000) } } ) } var body: some View { var view = AnyView( contentAndSidebarLayout .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls .padding(.leading, 10) .padding(.top, 4) } } .frame(minWidth: 800, minHeight: 600) .background(Color.clear) ) view = AnyView(view.onAppear { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId installSidebarResizerPointerMonitorIfNeeded() if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } updateTitlebarText() }) view = AnyView(view.onChange(of: tabManager.selectedTabId) { newValue in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))" ) } else { dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))") } #endif tabManager.applyWindowBackgroundForSelectedTab() startWorkspaceHandoffIfNeeded(newSelectedId: newValue) reconcileMountedWorkspaceIds(selectedId: newValue) guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } } updateTitlebarText() }) view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)" ) } else { dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)") } #endif reconcileMountedWorkspaceIds() }) view = AnyView(view.onChange(of: retiringWorkspaceId) { _ in reconcileMountedWorkspaceIds() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } scheduleTitlebarTextRefresh() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in sidebarSelectionState.selection = .tabs scheduleTitlebarTextRefresh() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") scheduleTitlebarTextRefresh() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in scheduleTitlebarThemeRefresh() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { _ in scheduleTitlebarThemeRefresh() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") }) view = AnyView(view.onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { self.retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil } if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) { self.previousSelectedWorkspaceId = tabManager.selectedTabId } reconcileMountedWorkspaceIds(tabs: tabs) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] } if let lastIndex = lastSidebarSelectionIndex, lastIndex >= tabs.count { if let selectedId = tabManager.selectedTabId { lastSidebarSelectionIndex = tabs.firstIndex { $0.id == selectedId } } else { lastSidebarSelectionIndex = nil } } }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in let tabId = SidebarDragLifecycleNotification.tabId(from: notification) sidebarDraggedTabId = tabId #if DEBUG dlog( "sidebar.dragState.content tab=\(debugShortWorkspaceId(tabId)) " + "reason=\(SidebarDragLifecycleNotification.reason(from: notification))" ) #endif }) view = AnyView(view.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }) view = AnyView(view.onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { notification in guard let window = notification.object as? NSWindow, window === observedWindow else { return } isFullScreen = true setTitlebarControlsHidden(true, in: window) AppDelegate.shared?.fullscreenControlsViewModel = fullscreenControlsViewModel }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { notification in guard let window = notification.object as? NSWindow, window === observedWindow else { return } isFullScreen = false setTitlebarControlsHidden(false, in: window) 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 // Do not make the entire background draggable; it interferes with drag gestures // like sidebar tab reordering in multi-window mode. window.isMovableByWindowBackground = false window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications if observedWindow !== window { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() } } // Keep content below the titlebar so drags on Bonsplit's tab bar don't // get interpreted as window drags. let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height let nextPadding = max(28, min(72, computedTitlebarHeight)) if abs(titlebarPadding - nextPadding) > 0.5 { DispatchQueue.main.async { titlebarPadding = nextPadding } } #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") } #endif // Background glass: skip on macOS 26+ where NSGlassEffectView can cause blank // or incorrectly tinted SwiftUI content. Keep native window rendering there so // Ghostty theme colors remain authoritative. if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled && !WindowGlassEffect.isAvailable { window.isOpaque = false window.backgroundColor = .clear // Configure contentView and all subviews for transparency if let contentView = window.contentView { contentView.wantsLayer = true contentView.layer?.backgroundColor = NSColor.clear.cgColor contentView.layer?.isOpaque = false // Make SwiftUI hosting view transparent for subview in contentView.subviews { subview.wantsLayer = true subview.layer?.backgroundColor = NSColor.clear.cgColor subview.layer?.isOpaque = false } } // Apply liquid glass effect to the window with tint from settings let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) WindowGlassEffect.apply(to: window, tintColor: tintColor) } AppDelegate.shared?.attachUpdateAccessory(to: window) AppDelegate.shared?.applyWindowDecorations(to: window) AppDelegate.shared?.registerMainWindow( window, windowId: windowId, tabManager: tabManager, sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState ) installFileDropOverlay(on: window, tabManager: tabManager) })) return view } private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) { let currentTabs = tabs ?? tabManager.tabs let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] let isCycleHot = tabManager.isWorkspaceCycleHot let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair ? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle : WorkspaceMountPolicy.maxMountedWorkspaces let selectedCount = effectiveSelectedId == nil ? 0 : 1 let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count) let previousMountedIds = mountedWorkspaceIds mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds( current: mountedWorkspaceIds, selected: effectiveSelectedId, pinnedIds: pinnedIds, orderedTabIds: orderedTabIds, isCycleHot: isCycleHot, maxMounted: maxMounted ) #if DEBUG if mountedWorkspaceIds != previousMountedIds { let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) } let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) } if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " + "selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " + "added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))" ) } else { dlog( "ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " + "mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))" ) } } #endif } private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs } private func updateWindowGlassTint() { // Find this view's main window by identifier (keyWindow might be a debug panel/settings). guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return } let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) WindowGlassEffect.updateTint(to: window, color: tintColor) } private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) { let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls") for accessory in window.titlebarAccessoryViewControllers { if accessory.view.identifier == controlsId { accessory.isHidden = hidden accessory.view.alphaValue = hidden ? 0 : 1 } } } private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) { let oldSelectedId = previousSelectedWorkspaceId previousSelectedWorkspaceId = newSelectedId guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else { tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff") retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil return } workspaceHandoffGeneration &+= 1 let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " + "new=\(debugShortWorkspaceId(newSelectedId))" ) } else { dlog( "ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))" ) } #endif workspaceHandoffFallbackTask = Task { [generation] in do { try await Task.sleep(nanoseconds: 150_000_000) } catch { return } await MainActor.run { guard workspaceHandoffGeneration == generation else { return } completeWorkspaceHandoff(reason: "timeout") } } } private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) { guard focusedTabId == tabManager.selectedTabId else { return } guard retiringWorkspaceId != nil else { return } completeWorkspaceHandoff(reason: reason) } private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil let retiring = retiringWorkspaceId // Hide terminal portal views for the retiring workspace BEFORE clearing // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts // the workspace — but dismantleNSView intentionally doesn't hide portal views // (to avoid blackouts during transient bonsplit dismantles). Hiding here // prevents stale portal-hosted terminals from covering browser panes. if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { workspace.hideAllTerminalPortalViews() } retiringWorkspaceId = nil tabManager.completePendingWorkspaceUnfocus(reason: reason) #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))" ) } else { dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))") } #endif } #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } private func debugShortWorkspaceIds(_ ids: [UUID]) -> String { if ids.isEmpty { return "[]" } return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]" } private func debugMsText(_ ms: Double) -> String { String(format: "%.2fms", ms) } #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 @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 private let tabRowSpacing: CGFloat = 2 var body: some View { VStack(spacing: 0) { GeometryReader { proxy in ScrollView { VStack(spacing: 0) { // Space for traffic lights / fullscreen controls Spacer() .frame(height: trafficLightPadding) LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( tab: tab, index: index, rowSpacing: tabRowSpacing, selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, showsCommandShortcutHints: commandKeyMonitor.isCommandPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator ) } } .padding(.vertical, 8) SidebarEmptyArea( rowSpacing: tabRowSpacing, selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator ) .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(minHeight: proxy.size.height, alignment: .top) } .background( SidebarScrollViewResolver { scrollView in dragAutoScrollController.attach(scrollView: scrollView) } .frame(width: 0, height: 0) ) .overlay(alignment: .top) { SidebarTopScrim(height: trafficLightPadding + 20) .allowsHitTesting(false) } .overlay(alignment: .top) { // Double-click the sidebar title-bar area to zoom the // window, matching the panel top-bar behaviour. DoubleClickZoomView() .frame(height: trafficLightPadding) } .background(Color.clear) .modifier(ClearScrollBackground()) } #if DEBUG SidebarDevFooter(updateViewModel: updateViewModel) .frame(maxWidth: .infinity, alignment: .leading) #else UpdatePill(model: updateViewModel) .padding(.horizontal, 10) .padding(.bottom, 10) .frame(maxWidth: .infinity, alignment: .leading) #endif } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background(SidebarBackdrop().ignoresSafeArea()) .background( WindowAccessor { window in commandKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) .onAppear { commandKeyMonitor.start() draggedTabId = nil dropIndicator = nil SidebarDragLifecycleNotification.postStateDidChange( tabId: nil, reason: "sidebar_appear" ) } .onDisappear { commandKeyMonitor.stop() dragAutoScrollController.stop() dragFailsafeMonitor.stop() draggedTabId = nil dropIndicator = nil SidebarDragLifecycleNotification.postStateDidChange( tabId: nil, reason: "sidebar_disappear" ) } .onChange(of: draggedTabId) { newDraggedTabId in SidebarDragLifecycleNotification.postStateDidChange( tabId: newDraggedTabId, reason: "drag_state_change" ) #if DEBUG dlog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))") #endif if newDraggedTabId != nil { dragFailsafeMonitor.start { SidebarDragLifecycleNotification.postClearRequest(reason: $0) } return } dragFailsafeMonitor.stop() dragAutoScrollController.stop() dropIndicator = nil } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) #if DEBUG dlog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)") #endif draggedTabId = nil } } private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } } enum SidebarCommandHintPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] } static func isCurrentWindow( hostWindowNumber: Int?, hostWindowIsKey: Bool, eventWindowNumber: Int?, keyWindowNumber: Int? ) -> Bool { guard let hostWindowNumber, hostWindowIsKey else { return false } if let eventWindowNumber { return eventWindowNumber == hostWindowNumber } return keyWindowNumber == hostWindowNumber } static func shouldShowHints( for modifierFlags: NSEvent.ModifierFlags, hostWindowNumber: Int?, hostWindowIsKey: Bool, eventWindowNumber: Int?, keyWindowNumber: Int? ) -> Bool { shouldShowHints(for: modifierFlags) && isCurrentWindow( hostWindowNumber: hostWindowNumber, hostWindowIsKey: hostWindowIsKey, eventWindowNumber: eventWindowNumber, keyWindowNumber: keyWindowNumber ) } } enum ShortcutHintDebugSettings { static let sidebarHintXKey = "shortcutHintSidebarXOffset" static let sidebarHintYKey = "shortcutHintSidebarYOffset" static let titlebarHintXKey = "shortcutHintTitlebarXOffset" static let titlebarHintYKey = "shortcutHintTitlebarYOffset" static let paneHintXKey = "shortcutHintPaneTabXOffset" static let paneHintYKey = "shortcutHintPaneTabYOffset" static let alwaysShowHintsKey = "shortcutHintAlwaysShow" static let defaultSidebarHintX = 0.0 static let defaultSidebarHintY = 0.0 static let defaultTitlebarHintX = 4.0 static let defaultTitlebarHintY = 0.0 static let defaultPaneHintX = 0.0 static let defaultPaneHintY = 0.0 static let defaultAlwaysShowHints = false static let offsetRange: ClosedRange = -20...20 static func clamped(_ value: Double) -> Double { min(max(value, offsetRange.lowerBound), offsetRange.upperBound) } } enum SidebarDragLifecycleNotification { static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") static let tabIdKey = "tabId" static let reasonKey = "reason" static func postStateDidChange(tabId: UUID?, reason: String) { var userInfo: [AnyHashable: Any] = [reasonKey: reason] if let tabId { userInfo[tabIdKey] = tabId } NotificationCenter.default.post( name: stateDidChange, object: nil, userInfo: userInfo ) } static func postClearRequest(reason: String) { NotificationCenter.default.post( name: requestClear, object: nil, userInfo: [reasonKey: reason] ) } static func tabId(from notification: Notification) -> UUID? { notification.userInfo?[tabIdKey] as? UUID } static func reason(from notification: Notification) -> String { notification.userInfo?[reasonKey] as? String ?? "unknown" } } enum SidebarOutsideDropResetPolicy { static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { draggedTabId != nil && hasSidebarDragPayload } } enum SidebarDragFailsafePolicy { static let pollInterval: TimeInterval = 0.05 static let clearDelay: TimeInterval = 0.15 static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { isDragActive && !isLeftMouseButtonDown } } @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 private var timer: Timer? private var pendingClearWorkItem: DispatchWorkItem? private var appResignObserver: NSObjectProtocol? private var keyDownMonitor: Any? private var onRequestClear: ((String) -> Void)? func start(onRequestClear: @escaping (String) -> Void) { self.onRequestClear = onRequestClear if timer == nil { let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.tick() } } self.timer = timer RunLoop.main.add(timer, forMode: .common) } if appResignObserver == nil { appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.requestClearSoon(reason: "app_resign_active") } } } if keyDownMonitor == nil { keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if event.keyCode == Self.escapeKeyCode { self?.requestClearSoon(reason: "escape_cancel") } return event } } } func stop() { timer?.invalidate() timer = nil pendingClearWorkItem?.cancel() pendingClearWorkItem = nil if let appResignObserver { NotificationCenter.default.removeObserver(appResignObserver) self.appResignObserver = nil } if let keyDownMonitor { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } onRequestClear = nil } private func tick() { let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) guard SidebarDragFailsafePolicy.shouldRequestClear( isDragActive: true, // Monitor only runs while drag is active. isLeftMouseButtonDown: isLeftMouseButtonDown ) else { return } requestClearSoon(reason: "mouse_up_failsafe") } private func requestClearSoon(reason: String) { guard pendingClearWorkItem == nil else { return } #if DEBUG dlog("sidebar.dragFailsafe.schedule reason=\(reason)") #endif let workItem = DispatchWorkItem { [weak self] in #if DEBUG dlog("sidebar.dragFailsafe.fire reason=\(reason)") #endif self?.pendingClearWorkItem = nil self?.onRequestClear?(reason) } pendingClearWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) } } private struct SidebarExternalDropOverlay: View { let draggedTabId: UUID? var body: some View { let dragPasteboardTypes = NSPasteboard(name: .drag).types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( draggedTabId: draggedTabId, pasteboardTypes: dragPasteboardTypes ) Group { if shouldCapture { Color.clear .contentShape(Rectangle()) .allowsHitTesting(true) .onDrop( of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) ) } else { Color.clear .contentShape(Rectangle()) .allowsHitTesting(false) } } } } private struct SidebarExternalDropDelegate: DropDelegate { let draggedTabId: UUID? func validateDrop(info: DropInfo) -> Bool { let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( draggedTabId: draggedTabId, hasSidebarDragPayload: hasSidebarPayload ) #if DEBUG dlog( "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" ) #endif return shouldReset } func dropEntered(info: DropInfo) { #if DEBUG dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") #endif } func dropExited(info: DropInfo) { #if DEBUG dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") #endif } func dropUpdated(info: DropInfo) -> DropProposal? { guard validateDrop(info: info) else { return nil } #if DEBUG dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") #endif // Explicit move proposal avoids AppKit showing a copy (+) cursor. return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { guard validateDrop(info: info) else { return false } #if DEBUG dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") #endif SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") return true } private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } } @MainActor private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false private weak var hostWindow: NSWindow? private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? func setHostWindow(_ window: NSWindow?) { guard hostWindow !== window else { return } removeHostWindowObservers() hostWindow = window guard let window else { cancelPendingHintShow(resetVisible: true) return } hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.update(from: NSEvent.modifierFlags, eventWindow: nil) } } hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.cancelPendingHintShow(resetVisible: true) } } update(from: NSEvent.modifierFlags, eventWindow: nil) } func start() { guard flagsMonitor == nil else { update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handleKeyDown(event) return event } appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.cancelPendingHintShow(resetVisible: true) } } update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { if let flagsMonitor { NSEvent.removeMonitor(flagsMonitor) self.flagsMonitor = nil } if let keyDownMonitor { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } if let appResignObserver { NotificationCenter.default.removeObserver(appResignObserver) self.appResignObserver = nil } removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } private func handleKeyDown(_ event: NSEvent) { guard isCurrentWindow(eventWindow: event.window) else { return } cancelPendingHintShow(resetVisible: true) } private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { SidebarCommandHintPolicy.isCurrentWindow( hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, keyWindowNumber: NSApp.keyWindow?.windowNumber ) } private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { guard SidebarCommandHintPolicy.shouldShowHints( for: modifierFlags, hostWindowNumber: hostWindow?.windowNumber, hostWindowIsKey: hostWindow?.isKeyWindow ?? false, eventWindowNumber: eventWindow?.windowNumber, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { cancelPendingHintShow(resetVisible: true) return } queueHintShow() } private func queueHintShow() { guard !isCommandPressed else { return } guard pendingShowWorkItem == nil else { return } let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil guard SidebarCommandHintPolicy.shouldShowHints( for: NSEvent.modifierFlags, hostWindowNumber: self.hostWindow?.windowNumber, hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, eventWindowNumber: nil, keyWindowNumber: NSApp.keyWindow?.windowNumber ) else { return } self.isCommandPressed = true } pendingShowWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem) } private func cancelPendingHintShow(resetVisible: Bool) { pendingShowWorkItem?.cancel() pendingShowWorkItem = nil if resetVisible { isCommandPressed = false } } private func removeHostWindowObservers() { if let hostWindowDidBecomeKeyObserver { NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) self.hostWindowDidBecomeKeyObserver = nil } if let hostWindowDidResignKeyObserver { NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) self.hostWindowDidResignKeyObserver = nil } } } #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel var body: some View { VStack(alignment: .leading, spacing: 6) { UpdatePill(model: updateViewModel) Text("THIS IS A DEV BUILD") .font(.system(size: 11, weight: .semibold)) .foregroundColor(.red) } .padding(.horizontal, 10) .padding(.bottom, 10) } } #endif private struct SidebarTopScrim: View { let height: CGFloat var body: some View { SidebarTopBlurEffect() .frame(height: height) .mask( LinearGradient( colors: [ Color.black.opacity(0.95), Color.black.opacity(0.75), Color.black.opacity(0.35), Color.clear ], startPoint: .top, endPoint: .bottom ) ) } } private struct SidebarTopBlurEffect: NSViewRepresentable { func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = .withinWindow view.material = .underWindowBackground view.state = .active view.isEmphasized = false return view } func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } private struct SidebarScrollViewResolver: NSViewRepresentable { let onResolve: (NSScrollView?) -> Void func makeNSView(context: Context) -> SidebarScrollViewResolverView { let view = SidebarScrollViewResolverView() view.onResolve = onResolve return view } func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { nsView.onResolve = onResolve nsView.resolveScrollView() } } private final class SidebarScrollViewResolverView: NSView { var onResolve: ((NSScrollView?) -> Void)? override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() resolveScrollView() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() resolveScrollView() } func resolveScrollView() { DispatchQueue.main.async { [weak self] in guard let self else { return } onResolve?(self.enclosingScrollView) } } } private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? var body: some View { Color.clear .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { tabManager.addTab() if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } selection = .tabs } .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( targetTabId: nil, tabManager: tabManager, draggedTabId: $draggedTabId, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, targetRowHeight: nil, dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) .overlay(alignment: .top) { if shouldShowTopDropIndicator { Rectangle() .fill(Color.accentColor) .frame(height: 2) .padding(.horizontal, 8) .offset(y: -(rowSpacing / 2)) } } } private var shouldShowTopDropIndicator: Bool { guard draggedTabId != nil, let indicator = dropIndicator else { return false } if indicator.tabId == nil { return true } guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } return indicator.tabId == lastTabId } } private struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @ObservedObject var tab: Tab let index: Int let rowSpacing: CGFloat @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? let showsCommandShortcutHints: Bool let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true var isActive: Bool { tabManager.selectedTabId == tab.id } var isMultiSelected: Bool { selectedTabIds.contains(tab.id) } private var isBeingDragged: Bool { draggedTabId == tab.id } private var workspaceShortcutDigit: Int? { WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) } private var showCloseButton: Bool { isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { guard let workspaceShortcutDigit else { return nil } return "⌘\(workspaceShortcutDigit)" } private var showsWorkspaceShortcutHint: Bool { (showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil } private var workspaceHintSlotWidth: CGFloat { guard let label = workspaceShortcutLabel else { return 28 } let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + 2 return max(28, workspaceHintWidth(for: label) + positiveDebugInset) } private func workspaceHintWidth(for label: String) -> CGFloat { let font = NSFont.systemFont(ofSize: 10, weight: .semibold) let textWidth = (label as NSString).size(withAttributes: [.font: font]).width return ceil(textWidth) + 12 } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { let unreadCount = notificationStore.unreadCount(forTabId: tab.id) if unreadCount > 0 { ZStack { Circle() .fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) } .frame(width: 16, height: 16) } if tab.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) } Text(tab.title) .font(.system(size: 12.5, weight: .semibold)) .foregroundColor(isActive ? .white : .primary) .lineLimit(1) .truncationMode(.tail) Spacer() ZStack(alignment: .trailing) { Button(action: { #if DEBUG dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") #endif tabManager.closeWorkspaceWithConfirmation(tab) }) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) } .buttonStyle(.plain) .help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))") .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) if showsWorkspaceShortcutHint, let workspaceShortcutLabel { Text(workspaceShortcutLabel) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .font(.system(size: 10, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundColor(isActive ? .white : .primary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9)) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) ) .transition(.opacity) } } .animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints) .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } if let subtitle = latestNotificationText { Text(subtitle) .font(.system(size: 10)) .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) } if sidebarShowStatusPills, !tab.statusEntries.isEmpty { SidebarStatusPillsRow( entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } return lhs.key < rhs.key }), isActive: isActive, onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) } // Latest log entry if sidebarShowLog, let latestLog = tab.logEntries.last { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) .foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) Text(latestLog.message) .font(.system(size: 10)) .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .lineLimit(1) .truncationMode(.tail) } .transition(.opacity.combined(with: .move(edge: .top))) } // Progress bar if sidebarShowProgress, let progress = tab.progress { VStack(alignment: .leading, spacing: 2) { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) Capsule() .fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) .frame(width: max(0, geo.size.width * CGFloat(progress.value))) } } .frame(height: 3) if let label = progress.label { Text(label) .font(.system(size: 9)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) .lineLimit(1) } } .transition(.opacity.combined(with: .move(edge: .top))) } // Branch + directory row if let dirRow = branchDirectoryRow { HStack(spacing: 3) { if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) } Text(dirRow) .font(.system(size: 10, design: .monospaced)) .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .lineLimit(1) .truncationMode(.tail) } } // Ports row if sidebarShowPorts, !tab.listeningPorts.isEmpty { Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) .font(.system(size: 10, design: .monospaced)) .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) .lineLimit(1) .truncationMode(.tail) } } .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 6) .fill(backgroundColor) ) .padding(.horizontal, 6) .background { GeometryReader { proxy in Color.clear .onAppear { rowHeight = max(proxy.size.height, 1) } .onChange(of: proxy.size.height) { newHeight in rowHeight = max(newHeight, 1) } } } .contentShape(Rectangle()) .opacity(isBeingDragged ? 0.6 : 1) .overlay { MiddleClickCapture { #if DEBUG dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") #endif tabManager.closeWorkspaceWithConfirmation(tab) } } .overlay(alignment: .top) { if showsCenteredTopDropIndicator { Rectangle() .fill(Color.accentColor) .frame(height: 2) .padding(.horizontal, 8) .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) } } .onDrag { #if DEBUG dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") #endif draggedTabId = tab.id dropIndicator = nil return SidebarTabDragPayload.provider(for: tab.id) } .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( targetTabId: tab.id, tabManager: tabManager, draggedTabId: $draggedTabId, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, targetRowHeight: rowHeight, dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) .onTapGesture { updateSelection() } .onHover { hovering in isHovering = hovering } .accessibilityElement(children: .combine) .accessibilityLabel(Text(accessibilityTitle)) .accessibilityHint(Text("Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.")) .accessibilityAction(named: Text("Move Up")) { moveBy(-1) } .accessibilityAction(named: Text("Move Down")) { moveBy(1) } .contextMenu { let targetIds = contextTargetIds() let shouldPin = !tab.isPinned let pinLabel = targetIds.count > 1 ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") : (shouldPin ? "Pin Workspace" : "Unpin Workspace") let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace" let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read" let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread" Button(pinLabel) { for id in targetIds { if let tab = tabManager.tabs.first(where: { $0.id == id }) { tabManager.setPinned(tab, pinned: shouldPin) } } syncSelectionAfterMutation() } Button("Rename Workspace…") { promptRename() } if tab.hasCustomTitle { Button("Remove Custom Workspace Name") { tabManager.clearCustomTitle(tabId: tab.id) } } Divider() Button("Move Up") { moveBy(-1) } .disabled(index == 0) Button("Move Down") { moveBy(1) } .disabled(index >= tabManager.tabs.count - 1) Button("Move to Top") { tabManager.moveTabsToTop(Set(targetIds)) syncSelectionAfterMutation() } .disabled(targetIds.isEmpty) Divider() Button(closeLabel) { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) Button("Close Other Workspaces") { closeOtherTabs(targetIds) } .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) Button("Close Workspaces Below") { closeTabsBelow(tabId: tab.id) } .disabled(index >= tabManager.tabs.count - 1) Button("Close Workspaces Above") { closeTabsAbove(tabId: tab.id) } .disabled(index == 0) Divider() Button(markReadLabel) { markTabsRead(targetIds) } .disabled(!hasUnreadNotifications(in: targetIds)) Button(markUnreadLabel) { markTabsUnread(targetIds) } .disabled(!hasReadNotifications(in: targetIds)) } } private var backgroundColor: Color { if isActive { return Color.accentColor } if isMultiSelected { return Color.accentColor.opacity(0.25) } return Color.clear } private var showsCenteredTopDropIndicator: Bool { guard draggedTabId != nil, let indicator = dropIndicator else { return false } if indicator.tabId == tab.id && indicator.edge == .top { return true } guard indicator.edge == .bottom, let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), currentIndex > 0 else { return false } return tabManager.tabs[currentIndex - 1].id == indicator.tabId } private var accessibilityTitle: String { "\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)" } private func moveBy(_ delta: Int) { let targetIndex = index + delta guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } selectedTabIds = [tab.id] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } tabManager.selectTab(tab) selection = .tabs } private func updateSelection() { #if DEBUG let mods = NSEvent.modifierFlags var modStr = "" if mods.contains(.command) { modStr += "cmd " } if mods.contains(.shift) { modStr += "shift " } if mods.contains(.option) { modStr += "opt " } if mods.contains(.control) { modStr += "ctrl " } dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") #endif let modifiers = NSEvent.modifierFlags let isCommand = modifiers.contains(.command) let isShift = modifiers.contains(.shift) if isShift, let lastIndex = lastSidebarSelectionIndex { let lower = min(lastIndex, index) let upper = max(lastIndex, index) let rangeIds = tabManager.tabs[lower...upper].map { $0.id } if isCommand { selectedTabIds.formUnion(rangeIds) } else { selectedTabIds = Set(rangeIds) } } else if isCommand { if selectedTabIds.contains(tab.id) { selectedTabIds.remove(tab.id) } else { selectedTabIds.insert(tab.id) } } else { selectedTabIds = [tab.id] } lastSidebarSelectionIndex = index tabManager.selectTab(tab) selection = .tabs } private func contextTargetIds() -> [UUID] { let baseIds: Set = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id] return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil } } private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { let idsToClose = targetIds.filter { id in guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false } return allowPinned || !tab.isPinned } for id in idsToClose { if let tab = tabManager.tabs.first(where: { $0.id == id }) { tabManager.closeWorkspaceWithConfirmation(tab) } } selectedTabIds.subtract(idsToClose) syncSelectionAfterMutation() } private func closeOtherTabs(_ targetIds: [UUID]) { let keepIds = Set(targetIds) let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } closeTabs(idsToClose, allowPinned: false) } private func closeTabsBelow(tabId: UUID) { guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } closeTabs(idsToClose, allowPinned: false) } private func closeTabsAbove(tabId: UUID) { guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } closeTabs(idsToClose, allowPinned: false) } private func markTabsRead(_ targetIds: [UUID]) { for id in targetIds { notificationStore.markRead(forTabId: id) } } private func markTabsUnread(_ targetIds: [UUID]) { for id in targetIds { notificationStore.markUnread(forTabId: id) } } private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { let targetSet = Set(targetIds) return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } } private func hasReadNotifications(in targetIds: [UUID]) -> Bool { let targetSet = Set(targetIds) return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } } private func syncSelectionAfterMutation() { let existingIds = Set(tabManager.tabs.map { $0.id }) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] } if let selectedId = tabManager.selectedTabId { lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } } private var latestNotificationText: String? { guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } let text = notification.body.isEmpty ? notification.title : notification.body let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private var branchDirectoryRow: String? { var parts: [String] = [] // Git branch (if enabled and available) if sidebarShowGitBranch, let git = tab.gitBranch { let dirty = git.isDirty ? "*" : "" parts.append("\(git.branch)\(dirty)") } // Directory summary if let dirs = directorySummaryText { parts.append(dirs) } let result = parts.joined(separator: " · ") return result.isEmpty ? nil : result } private var directorySummaryText: String? { guard !tab.panels.isEmpty else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser.path var seen: Set = [] var entries: [String] = [] for panelId in tab.panels.keys { let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory let shortened = shortenPath(directory, home: home) guard !shortened.isEmpty else { continue } if seen.insert(shortened).inserted { entries.append(shortened) } } return entries.isEmpty ? nil : entries.joined(separator: " | ") } private func logLevelIcon(_ level: SidebarLogLevel) -> String { switch level { case .info: return "circle.fill" case .progress: return "arrowtriangle.right.fill" case .success: return "checkmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" case .error: return "xmark.circle.fill" } } private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { if isActive { switch level { case .info: return .white.opacity(0.5) case .progress: return .white.opacity(0.8) case .success: return .white.opacity(0.9) case .warning: return .white.opacity(0.9) case .error: return .white.opacity(0.9) } } switch level { case .info: return .secondary case .progress: return .blue case .success: return .green case .warning: return .orange case .error: return .red } } private func shortenPath(_ path: String, home: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return path } if trimmed == home { return "~" } if trimmed.hasPrefix(home + "/") { return "~" + trimmed.dropFirst(home.count) } return trimmed } private func promptRename() { let alert = NSAlert() alert.messageText = "Rename Workspace" alert.informativeText = "Enter a custom name for this workspace." let input = NSTextField(string: tab.customTitle ?? tab.title) input.placeholderString = "Workspace name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input alert.addButton(withTitle: "Rename") alert.addButton(withTitle: "Cancel") let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { alertWindow.makeFirstResponder(input) input.selectText(nil) } let response = alert.runModal() guard response == .alertFirstButtonReturn else { return } tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) } } private struct SidebarStatusPillsRow: View { let entries: [SidebarStatusEntry] let isActive: Bool let onFocus: () -> Void @State private var isExpanded: Bool = false var body: some View { VStack(alignment: .leading, spacing: 2) { Text(statusText) .font(.system(size: 10)) .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) .lineLimit(isExpanded ? nil : 3) .truncationMode(.tail) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { onFocus() guard shouldShowToggle else { return } withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } } if shouldShowToggle { Button(isExpanded ? "Show less" : "Show more") { onFocus() withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } } .buttonStyle(.plain) .font(.system(size: 10, weight: .semibold)) .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) .frame(maxWidth: .infinity, alignment: .leading) } } .help(statusText) } private var statusText: String { entries .map { entry in let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } return entry.key } .joined(separator: "\n") } private var shouldShowToggle: Bool { entries.count > 1 || statusText.count > 120 } } enum SidebarDropEdge { case top case bottom } struct SidebarDropIndicator { let tabId: UUID? let edge: SidebarDropEdge } enum SidebarDropPlanner { static func indicator( draggedTabId: UUID?, targetTabId: UUID?, tabIds: [UUID], pointerY: CGFloat? = nil, targetHeight: CGFloat? = nil ) -> SidebarDropIndicator? { guard tabIds.count > 1, let draggedTabId else { return nil } guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } let insertionPosition: Int if let targetTabId { guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } let edge: SidebarDropEdge if let pointerY, let targetHeight { edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) } else { edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) } insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex } else { insertionPosition = tabIds.count } let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) guard targetIndex != fromIndex else { return nil } return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds) } static func targetIndex( draggedTabId: UUID, targetTabId: UUID?, indicator: SidebarDropIndicator?, tabIds: [UUID] ) -> Int? { guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } let insertionPosition: Int if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { insertionPosition = indicatorInsertion } else if let targetTabId { guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } let edge = (indicator?.tabId == targetTabId) ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex } else { insertionPosition = tabIds.count } return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count) } private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) if clampedInsertion >= tabIds.count { return SidebarDropIndicator(tabId: nil, edge: .bottom) } return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) } private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { if let tabId = indicator.tabId { guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex } return tabIds.count } private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } return fromIndex < targetIndex ? .bottom : .top } static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { guard targetHeight > 0 else { return .top } let clampedY = min(max(locationY, 0), targetHeight) return clampedY < (targetHeight / 2) ? .top : .bottom } private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { let clampedInsertion = max(0, min(insertionPosition, totalCount)) let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion return max(0, min(adjusted, max(0, totalCount - 1))) } } enum SidebarAutoScrollDirection: Equatable { case up case down } struct SidebarAutoScrollPlan: Equatable { let direction: SidebarAutoScrollDirection let pointsPerTick: CGFloat } enum SidebarDragAutoScrollPlanner { static let edgeInset: CGFloat = 44 static let minStep: CGFloat = 2 static let maxStep: CGFloat = 12 static func plan( distanceToTop: CGFloat, distanceToBottom: CGFloat, edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep ) -> SidebarAutoScrollPlan? { guard edgeInset > 0, maxStep >= minStep else { return nil } if distanceToTop <= edgeInset { let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) let step = minStep + ((maxStep - minStep) * normalized) return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) } if distanceToBottom <= edgeInset { let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) let step = minStep + ((maxStep - minStep) * normalized) return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) } return nil } } @MainActor private final class SidebarDragAutoScrollController: ObservableObject { private weak var scrollView: NSScrollView? private var timer: Timer? private var activePlan: SidebarAutoScrollPlan? func attach(scrollView: NSScrollView?) { self.scrollView = scrollView } func updateFromDragLocation() { guard let scrollView else { stop() return } guard let plan = plan(for: scrollView) else { stop() return } activePlan = plan startTimerIfNeeded() } func stop() { timer?.invalidate() timer = nil activePlan = nil } private func startTimerIfNeeded() { guard timer == nil else { return } let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in self?.tick() } } self.timer = timer RunLoop.main.add(timer, forMode: .eventTracking) } private func tick() { guard NSEvent.pressedMouseButtons != 0 else { stop() return } guard let scrollView else { stop() return } // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) // when periodic drag updates are available; use it first. if applyNativeAutoscroll(to: scrollView) { activePlan = plan(for: scrollView) if activePlan == nil { stop() } return } activePlan = self.plan(for: scrollView) guard let plan = activePlan else { stop() return } _ = apply(plan: plan, to: scrollView) } private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { guard let event = NSApp.currentEvent else { return false } switch event.type { case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: break default: return false } let clipView = scrollView.contentView let didScroll = clipView.autoscroll(with: event) if didScroll { scrollView.reflectScrolledClipView(clipView) } return didScroll } private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { if isFlipped { return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) } return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) } private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { let viewportHeight = clipView.bounds.height guard viewportHeight > 0 else { return nil } let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) } private func mousePoint(in clipView: NSClipView) -> CGPoint { let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero return clipView.convert(mouseInWindow, from: nil) } private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { let clipView = scrollView.contentView let mouse = mousePoint(in: clipView) return planForMousePoint(mouse, in: clipView) } private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { currentPlan(for: scrollView) } private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { guard let documentView = scrollView.documentView else { return false } let clipView = scrollView.contentView let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) guard maxOriginY > 0 else { return false } let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick let currentY = clipView.bounds.origin.y let targetY = min(max(currentY + delta, 0), maxOriginY) guard abs(targetY - currentY) > 0.01 else { return false } clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) scrollView.reflectScrolledClipView(clipView) return true } } private enum SidebarTabDragPayload { static let typeIdentifier = "com.cmux.sidebar-tab-reorder" private static let prefix = "cmux.sidebar-tab." static func provider(for tabId: UUID) -> NSItemProvider { let provider = NSItemProvider() let payload = "\(prefix)\(tabId.uuidString)" provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in completion(payload.data(using: .utf8), nil) return nil } return provider } } private struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager @Binding var draggedTabId: UUID? @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? let targetRowHeight: CGFloat? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? func validateDrop(info: DropInfo) -> Bool { let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) let hasDrag = draggedTabId != nil #if DEBUG dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") #endif return hasType && hasDrag } func dropEntered(info: DropInfo) { #if DEBUG dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) } func dropExited(info: DropInfo) { #if DEBUG dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif if dropIndicator?.tabId == targetTabId { dropIndicator = nil } } func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) #if DEBUG dlog( "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + "indicator=\(debugIndicator(dropIndicator))" ) #endif return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { defer { draggedTabId = nil dropIndicator = nil dragAutoScrollController.stop() } #if DEBUG dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif guard let draggedTabId else { #if DEBUG dlog("sidebar.drop.abort reason=missingDraggedTab") #endif return false } guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { #if DEBUG dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") #endif return false } let tabIds = tabManager.tabs.map(\.id) guard let targetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, targetTabId: targetTabId, indicator: dropIndicator, tabIds: tabIds ) else { #if DEBUG dlog( "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" ) #endif return false } guard fromIndex != targetIndex else { #if DEBUG dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") #endif syncSidebarSelection() return true } #if DEBUG dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") #endif _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] syncSidebarSelection(preferredSelectedTabId: selectedId) } else { selectedTabIds = [] syncSidebarSelection() } return true } private func updateDropIndicator(for info: DropInfo) { let tabIds = tabManager.tabs.map(\.id) dropIndicator = SidebarDropPlanner.indicator( draggedTabId: draggedTabId, targetTabId: targetTabId, tabIds: tabIds, pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ) } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId if let selectedId { lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } else { lastSidebarSelectionIndex = nil } } private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { guard let indicator else { return "nil" } let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" } } /// AppKit-level double-click handler for the sidebar title-bar area. /// Uses NSView hit-testing so it isn't swallowed by the SwiftUI ScrollView underneath. private struct DoubleClickZoomView: NSViewRepresentable { func makeNSView(context: Context) -> NSView { DoubleClickZoomNSView() } func updateNSView(_ nsView: NSView, context: Context) {} private final class DoubleClickZoomNSView: NSView { override var mouseDownCanMoveWindow: Bool { true } override func hitTest(_ point: NSPoint) -> NSView? { self } override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { window?.zoom(nil) } else { super.mouseDown(with: event) } } } } private struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void func makeNSView(context: Context) -> MiddleClickCaptureView { let view = MiddleClickCaptureView() view.onMiddleClick = onMiddleClick return view } func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { nsView.onMiddleClick = onMiddleClick } } private final class MiddleClickCaptureView: NSView { var onMiddleClick: (() -> Void)? override func hitTest(_ point: NSPoint) -> NSView? { // Only intercept middle-click so left-click selection and right-click context menus // continue to hit-test through to SwiftUI/AppKit normally. guard let event = NSApp.currentEvent, event.type == .otherMouseDown, event.buttonNumber == 2 else { return nil } return self } override func otherMouseDown(with event: NSEvent) { guard event.buttonNumber == 2 else { super.otherMouseDown(with: event) return } onMiddleClick?() } } enum SidebarSelection { case tabs case notifications } private struct ClearScrollBackground: ViewModifier { func body(content: Content) -> some View { if #available(macOS 13.0, *) { content .scrollContentBackground(.hidden) .background(ScrollBackgroundClearer()) } else { content .background(ScrollBackgroundClearer()) } } } private struct ScrollBackgroundClearer: NSViewRepresentable { func makeNSView(context: Context) -> NSView { NSView() } func updateNSView(_ nsView: NSView, context: Context) { DispatchQueue.main.async { guard let scrollView = findScrollView(startingAt: nsView) else { return } // Clear all backgrounds and mark as non-opaque for transparency scrollView.drawsBackground = false scrollView.backgroundColor = .clear scrollView.wantsLayer = true scrollView.layer?.backgroundColor = NSColor.clear.cgColor scrollView.layer?.isOpaque = false scrollView.contentView.drawsBackground = false scrollView.contentView.backgroundColor = .clear scrollView.contentView.wantsLayer = true scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor scrollView.contentView.layer?.isOpaque = false if let docView = scrollView.documentView { docView.wantsLayer = true docView.layer?.backgroundColor = NSColor.clear.cgColor docView.layer?.isOpaque = false } } } private func findScrollView(startingAt view: NSView) -> NSScrollView? { var current: NSView? = view while let candidate = current { if let scrollView = candidate as? NSScrollView { return scrollView } current = candidate.superview } return nil } } private struct DraggableFolderIcon: View { let directory: String var body: some View { DraggableFolderIconRepresentable(directory: directory) .frame(width: 16, height: 16) .help("Drag to open in Finder or another app") .onTapGesture(count: 2) { NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) } } } private struct DraggableFolderIconRepresentable: NSViewRepresentable { let directory: String func makeNSView(context: Context) -> DraggableFolderNSView { DraggableFolderNSView(directory: directory) } func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { nsView.directory = directory nsView.updateIcon() } } private final class DraggableFolderNSView: NSView, NSDraggingSource { var directory: String private var imageView: NSImageView! init(directory: String) { self.directory = directory super.init(frame: .zero) setupImageView() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var intrinsicContentSize: NSSize { NSSize(width: 16, height: 16) } private func setupImageView() { imageView = NSImageView() imageView.imageScaling = .scaleProportionallyDown imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), imageView.widthAnchor.constraint(equalToConstant: 16), imageView.heightAnchor.constraint(equalToConstant: 16), ]) updateIcon() } func updateIcon() { let icon = NSWorkspace.shared.icon(forFile: directory) icon.size = NSSize(width: 16, height: 16) imageView.image = icon } func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { return context == .outsideApplication ? [.copy, .link] : .copy } override func mouseDown(with event: NSEvent) { #if DEBUG dlog("folder.dragStart dir=\(directory)") #endif let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) let iconImage = NSWorkspace.shared.icon(forFile: directory) iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) beginDraggingSession(with: [draggingItem], event: event, source: self) } override func rightMouseDown(with event: NSEvent) { let menu = buildPathMenu() // Pop up menu at bottom-left of icon (like native proxy icon) let menuLocation = NSPoint(x: 0, y: bounds.height) menu.popUp(positioning: nil, at: menuLocation, in: self) } private func buildPathMenu() -> NSMenu { let menu = NSMenu() let url = URL(fileURLWithPath: directory).standardized var pathComponents: [URL] = [] // Build path from current directory up to root var current = url while current.path != "/" { pathComponents.append(current) current = current.deletingLastPathComponent() } pathComponents.append(URL(fileURLWithPath: "/")) // Add path components (current dir at top, root at bottom - matches native macOS) for pathURL in pathComponents { let icon = NSWorkspace.shared.icon(forFile: pathURL.path) icon.size = NSSize(width: 16, height: 16) let displayName: String if pathURL.path == "/" { // Use the volume name for root if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { displayName = volumeName } else { displayName = "Macintosh HD" } } else { displayName = FileManager.default.displayName(atPath: pathURL.path) } let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") item.target = self item.image = icon item.representedObject = pathURL menu.addItem(item) } // Add computer name at the bottom (like native proxy icon) let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() computerIcon.size = NSSize(width: 16, height: 16) let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") computerItem.target = self computerItem.image = computerIcon menu.addItem(computerItem) return menu } @objc private func openPathComponent(_ sender: NSMenuItem) { guard let url = sender.representedObject as? URL else { return } NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) } @objc private func openComputer(_ sender: NSMenuItem) { // Open "Computer" view in Finder (shows all volumes) NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) } } /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested private struct SidebarVisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode let state: NSVisualEffectView.State let opacity: Double let tintColor: NSColor? let cornerRadius: CGFloat let preferLiquidGlass: Bool init( material: NSVisualEffectView.Material = .hudWindow, blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, state: NSVisualEffectView.State = .active, opacity: Double = 1.0, tintColor: NSColor? = nil, cornerRadius: CGFloat = 0, preferLiquidGlass: Bool = false ) { self.material = material self.blendingMode = blendingMode self.state = state self.opacity = opacity self.tintColor = tintColor self.cornerRadius = cornerRadius self.preferLiquidGlass = preferLiquidGlass } static var liquidGlassAvailable: Bool { NSClassFromString("NSGlassEffectView") != nil } func makeNSView(context: Context) -> NSView { // Try NSGlassEffectView if preferred or if we want to test availability if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { let glass = glassClass.init(frame: .zero) glass.autoresizingMask = [.width, .height] glass.wantsLayer = true return glass } // Use NSVisualEffectView let view = NSVisualEffectView() view.autoresizingMask = [.width, .height] view.wantsLayer = true view.layerContentsRedrawPolicy = .onSetNeedsDisplay return view } func updateNSView(_ nsView: NSView, context: Context) { // Configure based on view type if nsView.className == "NSGlassEffectView" { // NSGlassEffectView configuration via private API nsView.alphaValue = max(0.0, min(1.0, opacity)) nsView.layer?.cornerRadius = cornerRadius nsView.layer?.masksToBounds = cornerRadius > 0 // Try to set tint color via private selector if let color = tintColor { let selector = NSSelectorFromString("setTintColor:") if nsView.responds(to: selector) { nsView.perform(selector, with: color) } } } else if let visualEffect = nsView as? NSVisualEffectView { // NSVisualEffectView configuration visualEffect.material = material visualEffect.blendingMode = blendingMode visualEffect.state = state visualEffect.alphaValue = max(0.0, min(1.0, opacity)) visualEffect.layer?.cornerRadius = cornerRadius visualEffect.layer?.masksToBounds = cornerRadius > 0 visualEffect.needsDisplay = true } } } /// Reads the leading inset required to clear traffic lights + left titlebar accessories. private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { let view = NSView() view.setFrameSize(.zero) return view } func updateNSView(_ nsView: NSView, context: Context) { DispatchQueue.main.async { guard let window = nsView.window else { return } // Start past the traffic lights var leading: CGFloat = 78 // Add width of all left-aligned titlebar accessories for accessory in window.titlebarAccessoryViewControllers where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { leading += accessory.view.frame.width } leading += 0 if leading != inset { inset = leading } } } } private struct SidebarBackdrop: View { @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18 @AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000" @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 var body: some View { let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active let tintColor = (NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) let useLiquidGlass = materialOption?.usesLiquidGlass ?? false let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow return ZStack { if let material = materialOption?.material { // When using liquidGlass + behindWindow, window handles glass + tint // Sidebar is fully transparent if !useWindowLevelGlass { SidebarVisualEffectBackground( material: material, blendingMode: blendingMode, state: state, opacity: sidebarBlurOpacity, tintColor: tintColor, cornerRadius: cornerRadius, preferLiquidGlass: useLiquidGlass ) // Tint overlay for NSVisualEffectView fallback if !useLiquidGlass { Color(nsColor: tintColor) } } } // When material is none or useWindowLevelGlass, render nothing } .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } } enum SidebarMaterialOption: String, CaseIterable, Identifiable { case none case liquidGlass // macOS 26+ NSGlassEffectView case sidebar case hudWindow case menu case popover case underWindowBackground case windowBackground case contentBackground case fullScreenUI case sheet case headerView case toolTip var id: String { rawValue } var title: String { switch self { case .none: return "None" case .liquidGlass: return "Liquid Glass (macOS 26+)" case .sidebar: return "Sidebar" case .hudWindow: return "HUD Window" case .menu: return "Menu" case .popover: return "Popover" case .underWindowBackground: return "Under Window" case .windowBackground: return "Window Background" case .contentBackground: return "Content Background" case .fullScreenUI: return "Full Screen UI" case .sheet: return "Sheet" case .headerView: return "Header View" case .toolTip: return "Tool Tip" } } /// Returns true if this option should use NSGlassEffectView (macOS 26+) var usesLiquidGlass: Bool { self == .liquidGlass } var material: NSVisualEffectView.Material? { switch self { case .none: return nil case .liquidGlass: return .underWindowBackground // Fallback material case .sidebar: return .sidebar case .hudWindow: return .hudWindow case .menu: return .menu case .popover: return .popover case .underWindowBackground: return .underWindowBackground case .windowBackground: return .windowBackground case .contentBackground: return .contentBackground case .fullScreenUI: return .fullScreenUI case .sheet: return .sheet case .headerView: return .headerView case .toolTip: return .toolTip } } } enum SidebarBlendModeOption: String, CaseIterable, Identifiable { case behindWindow case withinWindow var id: String { rawValue } var title: String { switch self { case .behindWindow: return "Behind Window" case .withinWindow: return "Within Window" } } var mode: NSVisualEffectView.BlendingMode { switch self { case .behindWindow: return .behindWindow case .withinWindow: return .withinWindow } } } enum SidebarStateOption: String, CaseIterable, Identifiable { case active case inactive case followWindow var id: String { rawValue } var title: String { switch self { case .active: return "Active" case .inactive: return "Inactive" case .followWindow: return "Follow Window" } } var state: NSVisualEffectView.State { switch self { case .active: return .active case .inactive: return .inactive case .followWindow: return .followsWindowActiveState } } } enum SidebarPresetOption: String, CaseIterable, Identifiable { case nativeSidebar case glassBehind case softBlur case popoverGlass case hudGlass case underWindow var id: String { rawValue } var title: String { switch self { case .nativeSidebar: return "Native Sidebar" case .glassBehind: return "Raycast Gray" case .softBlur: return "Soft Blur" case .popoverGlass: return "Popover Glass" case .hudGlass: return "HUD Glass" case .underWindow: return "Under Window" } } var material: SidebarMaterialOption { switch self { case .nativeSidebar: return .sidebar case .glassBehind: return .sidebar case .softBlur: return .sidebar case .popoverGlass: return .popover case .hudGlass: return .hudWindow case .underWindow: return .underWindowBackground } } var blendMode: SidebarBlendModeOption { switch self { case .nativeSidebar: return .withinWindow case .glassBehind: return .behindWindow case .softBlur: return .behindWindow case .popoverGlass: return .behindWindow case .hudGlass: return .withinWindow case .underWindow: return .withinWindow } } var state: SidebarStateOption { switch self { case .nativeSidebar: return .followWindow case .glassBehind: return .active case .softBlur: return .active case .popoverGlass: return .active case .hudGlass: return .active case .underWindow: return .followWindow } } var tintHex: String { switch self { case .nativeSidebar: return "#000000" case .glassBehind: return "#000000" case .softBlur: return "#000000" case .popoverGlass: return "#000000" case .hudGlass: return "#000000" case .underWindow: return "#000000" } } var tintOpacity: Double { switch self { case .nativeSidebar: return 0.18 case .glassBehind: return 0.36 case .softBlur: return 0.28 case .popoverGlass: return 0.10 case .hudGlass: return 0.62 case .underWindow: return 0.14 } } var cornerRadius: Double { switch self { case .nativeSidebar: return 0.0 case .glassBehind: return 0.0 case .softBlur: return 0.0 case .popoverGlass: return 10.0 case .hudGlass: return 10.0 case .underWindow: return 6.0 } } var blurOpacity: Double { switch self { case .nativeSidebar: return 1.0 case .glassBehind: return 0.6 case .softBlur: return 0.45 case .popoverGlass: return 0.9 case .hudGlass: return 0.98 case .underWindow: return 0.9 } } } extension NSColor { func hexString() -> String { let color = usingColorSpace(.sRGB) ?? self var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) return String( format: "#%02X%02X%02X", min(255, max(0, Int(red * 255))), min(255, max(0, Int(green * 255))), min(255, max(0, Int(blue * 255))) ) } }