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