import AppKit import Bonsplit import SwiftUI import ObjectiveC import UniformTypeIdentifiers 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() } } // MARK: - File Drop Overlay /// 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)? 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") } // MARK: Hit-testing — only participate when the system drag pasteboard contains file // URLs (i.e. a Finder file drag is in progress). For everything else — mouse events, // sidebar tab reorder, bonsplit tab drags — return nil so events route to the content // view below and SwiftUI / bonsplit drag-and-drop works normally. override func hitTest(_ point: NSPoint) -> NSView? { let pb = NSPasteboard(name: .drag) if let types = pb.types, types.contains(.fileURL) { return super.hitTest(point) } return nil } // 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 let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return } isHidden = true let point = themeFrame.convert(event.locationInWindow, from: nil) let target = contentView.hitTest(point) isHidden = false guard let target else { return } 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 } } 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 – only accept file drops over terminal views. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { return dragOperationForSender(sender) } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { return dragOperationForSender(sender) } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false } return terminal.performDragOperation(sender) } private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation { guard let types = sender.draggingPasteboard.types, types.contains(.fileURL), terminalUnderPoint(sender.draggingLocation) != nil else { return [] } return .copy } /// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor. private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { guard let window, let contentView = window.contentView, let themeFrame = contentView.superview else { return nil } isHidden = true let point = themeFrame.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) isHidden = false 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 /// 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 @Environment(\.colorScheme) private var colorScheme @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 sidebarMinX: CGFloat = 0 @State private var isResizerHovering = false @State private var isResizerDragging = false private let sidebarHandleWidth: CGFloat = 6 @State private var selectedTabIds: Set = [] @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() private var sidebarView: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) .background(GeometryReader { proxy in Color.clear .preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global)) }) .overlay(alignment: .trailing) { Color.clear .frame(width: sidebarHandleWidth) .contentShape(Rectangle()) .accessibilityIdentifier("SidebarResizer") .onHover { hovering in if hovering { if !isResizerHovering { NSCursor.resizeLeftRight.push() isResizerHovering = true } } else if isResizerHovering { if !isResizerDragging { NSCursor.pop() isResizerHovering = false } } } .onDisappear { if isResizerHovering || isResizerDragging { NSCursor.pop() isResizerHovering = false isResizerDragging = false } } .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { value in if !isResizerDragging { isResizerDragging = true #if DEBUG dlog("sidebar.resizeDragStart") #endif if !isResizerHovering { NSCursor.resizeLeftRight.push() isResizerHovering = true } } let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth } } .onEnded { _ in if isResizerDragging { isResizerDragging = false if !isResizerHovering { NSCursor.pop() } } } ) } } /// Space at top of content area for the titlebar. This must be at least the actual titlebar /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. @State private var titlebarPadding: CGFloat = 32 private var terminalContent: some View { ZStack { ZStack { ForEach(tabManager.tabs) { tab in let isActive = tabManager.selectedTabId == tab.id WorkspaceContentView(workspace: tab, isTabActive: isActive) .opacity(isActive ? 1 : 0) .allowsHitTesting(isActive) } } .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 } } @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 { if colorScheme == .light { return Color(nsColor: .windowBackgroundColor) } let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor let alpha: CGFloat = ghosttyBackground.isLightColor ? 0.94 : 0.86 return Color(nsColor: ghosttyBackground.withAlphaComponent(alpha)) } private var fakeTitlebarTextColor: Color { colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.78) : .secondary } private var fakeTitlebarSeparatorColor: Color { Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34) } 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 { titlebarText = "" return } let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) titlebarText = title } 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 } var body: some View { let useOverlay = sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue Group { if useOverlay { // Overlay mode: terminal extends full width, sidebar on top // This allows withinWindow blur to see the terminal content ZStack(alignment: .leading) { terminalContent .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView } } } else { // Standard HStack mode for behindWindow blur HStack(spacing: 0) { if sidebarState.isVisible { sidebarView } terminalContent } } } .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls .padding(.leading, 10) .padding(.top, 4) } } .frame(minWidth: 800, minHeight: 600) .background(Color.clear) .onAppear { tabManager.applyWindowBackgroundForSelectedTab() if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } updateTitlebarText() } .onChange(of: tabManager.selectedTabId) { newValue in tabManager.applyWindowBackgroundForSelectedTab() guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } } updateTitlebarText() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } updateTitlebarText() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in sidebarSelectionState.selection = .tabs updateTitlebarText() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } updateTitlebarText() } .onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) 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 } } } .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } .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 } .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 } .ignoresSafeArea() .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) } } // 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) }) } 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 } } } } 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() @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()) .onAppear { commandKeyMonitor.start() draggedTabId = nil dropIndicator = nil } .onDisappear { commandKeyMonitor.stop() dragAutoScrollController.stop() draggedTabId = nil dropIndicator = nil } .onChange(of: draggedTabId) { newDraggedTabId in guard newDraggedTabId == nil else { return } dragAutoScrollController.stop() dropIndicator = nil } } } enum SidebarCommandHintPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] } } 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) } } @MainActor private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false private var flagsMonitor: Any? private var keyDownMonitor: Any? private var resignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? func start() { guard flagsMonitor == nil else { update(from: NSEvent.modifierFlags) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in self?.update(from: event.modifierFlags) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.cancelPendingHintShow(resetVisible: true) return event } resignObserver = 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) } func stop() { if let flagsMonitor { NSEvent.removeMonitor(flagsMonitor) self.flagsMonitor = nil } if let keyDownMonitor { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } if let resignObserver { NotificationCenter.default.removeObserver(resignObserver) self.resignObserver = nil } cancelPendingHintShow(resetVisible: true) } private func update(from modifierFlags: NSEvent.ModifierFlags) { guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) 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) 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 } } } #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 SidebarFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() } } private struct SidebarScrollViewResolver: NSViewRepresentable { let onResolve: (NSScrollView?) -> Void 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 + ports 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) } } } .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 Tabs" : "Unpin Tabs") : (shouldPin ? "Pin Tab" : "Unpin Tab") Button(pinLabel) { for id in targetIds { if let tab = tabManager.tabs.first(where: { $0.id == id }) { tabManager.setPinned(tab, pinned: shouldPin) } } syncSelectionAfterMutation() } Button("Rename Tab…") { promptRename() } if tab.hasCustomTitle { Button("Remove Custom 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) Divider() Button("Close Tabs") { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) Button("Close Others") { closeOtherTabs(targetIds) } .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) Button("Close Tabs Below") { closeTabsBelow(tabId: tab.id) } .disabled(index >= tabManager.tabs.count - 1) Button("Close Tabs Above") { closeTabsAbove(tabId: tab.id) } .disabled(index == 0) Divider() Button("Move to Top") { tabManager.moveTabsToTop(Set(targetIds)) syncSelectionAfterMutation() } .disabled(targetIds.isEmpty) Divider() Button("Mark as Read") { markTabsRead(targetIds) } .disabled(!hasUnreadNotifications(in: targetIds)) Button("Mark as Unread") { 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) } // Ports (if enabled and available) if sidebarShowPorts, !tab.listeningPorts.isEmpty { let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",") parts.append(portsStr) } 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 Tab" alert.informativeText = "Enter a custom name for this tab." let input = NSTextField(string: tab.customTitle ?? tab.title) input.placeholderString = "Tab 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 dropIndicator?.tabId == targetTabId { dropIndicator = nil } } func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) 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 { return false } guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false } let tabIds = tabManager.tabs.map(\.id) guard let targetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, targetTabId: targetTabId, indicator: dropIndicator, tabIds: tabIds ) else { return false } guard fromIndex != targetIndex else { syncSidebarSelection() return true } _ = 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 } } } /// 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))) ) } }