cmux/Sources/ContentView.swift
2026-02-18 20:46:31 -08:00

2793 lines
106 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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()
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<UUID>
@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<Double> = -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<UUID>
@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<UUID>
@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<UUID> = 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<String> = []
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<UUID>
@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)))
)
}
}