Toggling isHidden during a display cycle calls _setHidden:setNeedsDisplay:, which posts another window-needs-display and pushes the pass count past AppKit's per-cycle limit, causing an NSException crash near resize handles. Remove the isHidden toggle from debugTopHitViewForCurrentEvent(); the hit test now returns the overlay itself when it is the topmost view, which is acceptable for debug logging purposes.
9148 lines
357 KiB
Swift
9148 lines
357 KiB
Swift
import AppKit
|
||
import Bonsplit
|
||
import SwiftUI
|
||
import ObjectiveC
|
||
import UniformTypeIdentifiers
|
||
import WebKit
|
||
|
||
private extension Color {
|
||
init?(hex: String) {
|
||
let hex = hex.trimmingCharacters(in: .init(charactersIn: "#"))
|
||
guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil }
|
||
self.init(
|
||
red: Double((value >> 16) & 0xFF) / 255.0,
|
||
green: Double((value >> 8) & 0xFF) / 255.0,
|
||
blue: Double( value & 0xFF) / 255.0
|
||
)
|
||
}
|
||
}
|
||
|
||
private func coloredCircleImage(color: NSColor) -> NSImage {
|
||
let size = NSSize(width: 14, height: 14)
|
||
let image = NSImage(size: size, flipped: false) { rect in
|
||
color.setFill()
|
||
NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill()
|
||
return true
|
||
}
|
||
image.isTemplate = false
|
||
return image
|
||
}
|
||
|
||
func sidebarActiveForegroundNSColor(
|
||
opacity: CGFloat,
|
||
appAppearance: NSAppearance? = NSApp?.effectiveAppearance
|
||
) -> NSColor {
|
||
let clampedOpacity = max(0, min(opacity, 1))
|
||
let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua])
|
||
let baseColor: NSColor = (bestMatch == .darkAqua) ? .white : .black
|
||
return baseColor.withAlphaComponent(clampedOpacity)
|
||
}
|
||
|
||
func cmuxAccentNSColor(for colorScheme: ColorScheme) -> NSColor {
|
||
switch colorScheme {
|
||
case .dark:
|
||
return NSColor(
|
||
srgbRed: 0,
|
||
green: 145.0 / 255.0,
|
||
blue: 1.0,
|
||
alpha: 1.0
|
||
)
|
||
default:
|
||
return NSColor(
|
||
srgbRed: 0,
|
||
green: 136.0 / 255.0,
|
||
blue: 1.0,
|
||
alpha: 1.0
|
||
)
|
||
}
|
||
}
|
||
|
||
func cmuxAccentNSColor(for appAppearance: NSAppearance?) -> NSColor {
|
||
let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua])
|
||
let scheme: ColorScheme = (bestMatch == .darkAqua) ? .dark : .light
|
||
return cmuxAccentNSColor(for: scheme)
|
||
}
|
||
|
||
func cmuxAccentNSColor() -> NSColor {
|
||
NSColor(name: nil) { appearance in
|
||
cmuxAccentNSColor(for: appearance)
|
||
}
|
||
}
|
||
|
||
func cmuxAccentColor() -> Color {
|
||
Color(nsColor: cmuxAccentNSColor())
|
||
}
|
||
|
||
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
|
||
cmuxAccentNSColor(for: colorScheme)
|
||
}
|
||
|
||
func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor {
|
||
let clampedOpacity = max(0, min(opacity, 1))
|
||
return NSColor.white.withAlphaComponent(clampedOpacity)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// CALayer-backed titlebar background. Uses layer-level opacity (not per-pixel alpha)
|
||
/// to match how the terminal's Metal surface composites its background.
|
||
struct TitlebarLayerBackground: NSViewRepresentable {
|
||
var backgroundColor: NSColor
|
||
var opacity: CGFloat
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = NSView()
|
||
view.wantsLayer = true
|
||
view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor
|
||
view.layer?.opacity = Float(opacity)
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor
|
||
nsView.layer?.opacity = Float(opacity)
|
||
}
|
||
}
|
||
|
||
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)
|
||
// Don't toggle isHidden here — it triggers setNeedsDisplay which can
|
||
// exceed AppKit's display-pass limit during cursor-update display cycles.
|
||
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
|
||
private var commandPaletteWindowOverlayKey: UInt8 = 0
|
||
let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container")
|
||
|
||
enum CommandPaletteOverlayPromotionPolicy {
|
||
static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool {
|
||
isVisible && !previouslyVisible
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class CommandPaletteOverlayContainerView: NSView {
|
||
var capturesMouseEvents = false
|
||
|
||
override var isOpaque: Bool { false }
|
||
override var acceptsFirstResponder: Bool { true }
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
guard capturesMouseEvents else { return nil }
|
||
return super.hitTest(point)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class WindowCommandPaletteOverlayController: NSObject {
|
||
private weak var window: NSWindow?
|
||
private let containerView = CommandPaletteOverlayContainerView(frame: .zero)
|
||
private let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
|
||
private var installConstraints: [NSLayoutConstraint] = []
|
||
private weak var installedThemeFrame: NSView?
|
||
private var focusLockTimer: DispatchSourceTimer?
|
||
private var scheduledFocusWorkItem: DispatchWorkItem?
|
||
private var isPaletteVisible = false
|
||
private var windowDidBecomeKeyObserver: NSObjectProtocol?
|
||
private var windowDidResignKeyObserver: NSObjectProtocol?
|
||
|
||
init(window: NSWindow) {
|
||
self.window = window
|
||
super.init()
|
||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||
containerView.wantsLayer = true
|
||
containerView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
containerView.isHidden = true
|
||
containerView.alphaValue = 0
|
||
containerView.capturesMouseEvents = false
|
||
containerView.identifier = commandPaletteOverlayContainerIdentifier
|
||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||
hostingView.wantsLayer = true
|
||
hostingView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
containerView.addSubview(hostingView)
|
||
NSLayoutConstraint.activate([
|
||
hostingView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||
hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||
hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||
hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||
])
|
||
_ = ensureInstalled()
|
||
installWindowKeyObservers()
|
||
}
|
||
|
||
@discardableResult
|
||
private func ensureInstalled() -> Bool {
|
||
guard let window,
|
||
let contentView = window.contentView,
|
||
let themeFrame = contentView.superview else { return false }
|
||
|
||
if containerView.superview !== themeFrame {
|
||
NSLayoutConstraint.deactivate(installConstraints)
|
||
installConstraints.removeAll()
|
||
containerView.removeFromSuperview()
|
||
themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||
installConstraints = [
|
||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||
containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||
containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||
containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||
]
|
||
NSLayoutConstraint.activate(installConstraints)
|
||
installedThemeFrame = themeFrame
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private func promoteOverlayAboveSiblingsIfNeeded() {
|
||
guard let themeFrame = installedThemeFrame,
|
||
containerView.superview === themeFrame else { return }
|
||
themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||
}
|
||
|
||
private func isPaletteResponder(_ responder: NSResponder?) -> Bool {
|
||
guard let responder else { return false }
|
||
|
||
if let view = responder as? NSView, view.isDescendant(of: containerView) {
|
||
return true
|
||
}
|
||
|
||
if let textView = responder as? NSTextView {
|
||
if let delegateView = textView.delegate as? NSView,
|
||
delegateView.isDescendant(of: containerView) {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func isPaletteFieldEditor(_ textView: NSTextView) -> Bool {
|
||
guard textView.isFieldEditor else { return false }
|
||
|
||
if let delegateView = textView.delegate as? NSView,
|
||
delegateView.isDescendant(of: containerView) {
|
||
return true
|
||
}
|
||
|
||
// SwiftUI text fields can keep a field editor delegate that isn't an NSView.
|
||
// Fall back to validating editor ownership from the mounted palette text field.
|
||
if let textField = firstEditableTextField(in: hostingView),
|
||
textField.currentEditor() === textView {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool {
|
||
guard let responder else { return false }
|
||
|
||
if let textView = responder as? NSTextView {
|
||
return isPaletteFieldEditor(textView)
|
||
}
|
||
|
||
if let textField = responder as? NSTextField {
|
||
return textField.isDescendant(of: containerView)
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
private func firstEditableTextField(in view: NSView) -> NSTextField? {
|
||
if let textField = view as? NSTextField,
|
||
textField.isEditable,
|
||
textField.isEnabled,
|
||
!textField.isHiddenOrHasHiddenAncestor {
|
||
return textField
|
||
}
|
||
|
||
for subview in view.subviews {
|
||
if let match = firstEditableTextField(in: subview) {
|
||
return match
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func scheduleFocusIntoPalette(retries: Int = 4) {
|
||
scheduledFocusWorkItem?.cancel()
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
self?.scheduledFocusWorkItem = nil
|
||
self?.focusIntoPalette(retries: retries)
|
||
}
|
||
scheduledFocusWorkItem = workItem
|
||
DispatchQueue.main.async(execute: workItem)
|
||
}
|
||
|
||
private func focusIntoPalette(retries: Int) {
|
||
guard let window else { return }
|
||
if isPaletteTextInputFirstResponder(window.firstResponder) {
|
||
return
|
||
}
|
||
|
||
if let textField = firstEditableTextField(in: hostingView),
|
||
window.makeFirstResponder(textField),
|
||
isPaletteTextInputFirstResponder(window.firstResponder) {
|
||
normalizeSelectionAfterProgrammaticFocus()
|
||
return
|
||
}
|
||
|
||
if window.makeFirstResponder(containerView) {
|
||
if let textField = firstEditableTextField(in: hostingView),
|
||
window.makeFirstResponder(textField),
|
||
isPaletteTextInputFirstResponder(window.firstResponder) {
|
||
normalizeSelectionAfterProgrammaticFocus()
|
||
return
|
||
}
|
||
}
|
||
|
||
guard retries > 0 else { return }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in
|
||
self?.focusIntoPalette(retries: retries - 1)
|
||
}
|
||
}
|
||
|
||
private func installWindowKeyObservers() {
|
||
guard let window else { return }
|
||
windowDidBecomeKeyObserver = NotificationCenter.default.addObserver(
|
||
forName: NSWindow.didBecomeKeyNotification,
|
||
object: window,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.updateFocusLockForWindowState()
|
||
}
|
||
}
|
||
windowDidResignKeyObserver = NotificationCenter.default.addObserver(
|
||
forName: NSWindow.didResignKeyNotification,
|
||
object: window,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.updateFocusLockForWindowState()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateFocusLockForWindowState() {
|
||
guard let window else {
|
||
stopFocusLockTimer()
|
||
return
|
||
}
|
||
guard isPaletteVisible else {
|
||
stopFocusLockTimer()
|
||
return
|
||
}
|
||
|
||
guard window.isKeyWindow else {
|
||
stopFocusLockTimer()
|
||
if isPaletteResponder(window.firstResponder) {
|
||
_ = window.makeFirstResponder(nil)
|
||
}
|
||
return
|
||
}
|
||
|
||
startFocusLockTimer()
|
||
if !isPaletteTextInputFirstResponder(window.firstResponder) {
|
||
scheduleFocusIntoPalette(retries: 8)
|
||
}
|
||
}
|
||
|
||
private func startFocusLockTimer() {
|
||
guard focusLockTimer == nil else { return }
|
||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||
timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12))
|
||
timer.setEventHandler { [weak self] in
|
||
guard let self else { return }
|
||
guard let window = self.window else {
|
||
self.stopFocusLockTimer()
|
||
return
|
||
}
|
||
if self.isPaletteTextInputFirstResponder(window.firstResponder) {
|
||
return
|
||
}
|
||
self.focusIntoPalette(retries: 1)
|
||
}
|
||
focusLockTimer = timer
|
||
timer.resume()
|
||
}
|
||
|
||
private func stopFocusLockTimer() {
|
||
focusLockTimer?.cancel()
|
||
focusLockTimer = nil
|
||
scheduledFocusWorkItem?.cancel()
|
||
scheduledFocusWorkItem = nil
|
||
}
|
||
|
||
private func normalizeSelectionAfterProgrammaticFocus() {
|
||
guard let window,
|
||
let editor = window.firstResponder as? NSTextView,
|
||
editor.isFieldEditor else { return }
|
||
|
||
let text = editor.string
|
||
let length = (text as NSString).length
|
||
let selection = editor.selectedRange()
|
||
guard length > 0 else { return }
|
||
guard selection.location == 0, selection.length == length else { return }
|
||
|
||
// Keep commands-mode prefix semantics stable after focus re-assertions:
|
||
// if AppKit selected the entire query (e.g. ">foo"), restore caret-at-end
|
||
// so the next keystroke appends instead of replacing and switching modes.
|
||
guard text.hasPrefix(">") else { return }
|
||
editor.setSelectedRange(NSRange(location: length, length: 0))
|
||
}
|
||
|
||
func update(rootView: AnyView, isVisible: Bool) {
|
||
guard ensureInstalled() else { return }
|
||
let shouldPromote = CommandPaletteOverlayPromotionPolicy.shouldPromote(
|
||
previouslyVisible: isPaletteVisible,
|
||
isVisible: isVisible
|
||
)
|
||
isPaletteVisible = isVisible
|
||
if isVisible {
|
||
hostingView.rootView = rootView
|
||
containerView.capturesMouseEvents = true
|
||
containerView.isHidden = false
|
||
containerView.alphaValue = 1
|
||
if shouldPromote {
|
||
promoteOverlayAboveSiblingsIfNeeded()
|
||
}
|
||
updateFocusLockForWindowState()
|
||
} else {
|
||
stopFocusLockTimer()
|
||
if let window, isPaletteResponder(window.firstResponder) {
|
||
_ = window.makeFirstResponder(nil)
|
||
}
|
||
hostingView.rootView = AnyView(EmptyView())
|
||
containerView.capturesMouseEvents = false
|
||
containerView.alphaValue = 0
|
||
containerView.isHidden = true
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController {
|
||
if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController {
|
||
return existing
|
||
}
|
||
let controller = WindowCommandPaletteOverlayController(window: window)
|
||
objc_setAssociatedObject(window, &commandPaletteWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||
return controller
|
||
}
|
||
|
||
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 sidebarResizerCursorReleaseWorkItem: DispatchWorkItem?
|
||
@State private var sidebarResizerPointerMonitor: Any?
|
||
@State private var isResizerBandActive = false
|
||
@State private var isSidebarResizerCursorActive = false
|
||
@State private var sidebarResizerCursorStabilizer: DispatchSourceTimer?
|
||
@State private var isCommandPalettePresented = false
|
||
@State private var commandPaletteQuery: String = ""
|
||
@State private var commandPaletteMode: CommandPaletteMode = .commands
|
||
@State private var commandPaletteRenameDraft: String = ""
|
||
@State private var commandPaletteSelectedResultIndex: Int = 0
|
||
@State private var commandPaletteHoveredResultIndex: Int?
|
||
@State private var commandPaletteScrollTargetIndex: Int?
|
||
@State private var commandPaletteScrollTargetAnchor: UnitPoint?
|
||
@State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget?
|
||
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
|
||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||
@FocusState private var isCommandPaletteSearchFocused: Bool
|
||
@FocusState private var isCommandPaletteRenameFocused: Bool
|
||
|
||
private enum CommandPaletteMode {
|
||
case commands
|
||
case renameInput(CommandPaletteRenameTarget)
|
||
case renameConfirm(CommandPaletteRenameTarget, proposedName: String)
|
||
}
|
||
|
||
private enum CommandPaletteListScope: String {
|
||
case commands
|
||
case switcher
|
||
}
|
||
|
||
private struct CommandPaletteRenameTarget: Equatable {
|
||
enum Kind: Equatable {
|
||
case workspace(workspaceId: UUID)
|
||
case tab(workspaceId: UUID, panelId: UUID)
|
||
}
|
||
|
||
let kind: Kind
|
||
let currentName: String
|
||
|
||
var title: String {
|
||
switch kind {
|
||
case .workspace:
|
||
return "Rename Workspace"
|
||
case .tab:
|
||
return "Rename Tab"
|
||
}
|
||
}
|
||
|
||
var description: String {
|
||
switch kind {
|
||
case .workspace:
|
||
return "Choose a custom workspace name."
|
||
case .tab:
|
||
return "Choose a custom tab name."
|
||
}
|
||
}
|
||
|
||
var placeholder: String {
|
||
switch kind {
|
||
case .workspace:
|
||
return "Workspace name"
|
||
case .tab:
|
||
return "Tab name"
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum CommandPaletteRestoreFocusIntent {
|
||
case panel
|
||
case browserAddressBar
|
||
}
|
||
|
||
private struct CommandPaletteRestoreFocusTarget {
|
||
let workspaceId: UUID
|
||
let panelId: UUID
|
||
let intent: CommandPaletteRestoreFocusIntent
|
||
}
|
||
|
||
private enum CommandPaletteInputFocusTarget {
|
||
case search
|
||
case rename
|
||
}
|
||
|
||
private enum CommandPaletteTextSelectionBehavior {
|
||
case caretAtEnd
|
||
case selectAll
|
||
}
|
||
|
||
private enum CommandPaletteTrailingLabelStyle {
|
||
case shortcut
|
||
case kind
|
||
}
|
||
|
||
private struct CommandPaletteTrailingLabel {
|
||
let text: String
|
||
let style: CommandPaletteTrailingLabelStyle
|
||
}
|
||
|
||
private struct CommandPaletteInputFocusPolicy {
|
||
let focusTarget: CommandPaletteInputFocusTarget
|
||
let selectionBehavior: CommandPaletteTextSelectionBehavior
|
||
|
||
static let search = CommandPaletteInputFocusPolicy(
|
||
focusTarget: .search,
|
||
selectionBehavior: .caretAtEnd
|
||
)
|
||
}
|
||
|
||
private struct CommandPaletteCommand: Identifiable {
|
||
let id: String
|
||
let rank: Int
|
||
let title: String
|
||
let subtitle: String
|
||
let shortcutHint: String?
|
||
let keywords: [String]
|
||
let dismissOnRun: Bool
|
||
let action: () -> Void
|
||
|
||
var searchableTexts: [String] {
|
||
[title, subtitle] + keywords
|
||
}
|
||
}
|
||
|
||
private struct CommandPaletteUsageEntry: Codable {
|
||
var useCount: Int
|
||
var lastUsedAt: TimeInterval
|
||
}
|
||
|
||
private struct CommandPaletteContextSnapshot {
|
||
private var boolValues: [String: Bool] = [:]
|
||
private var stringValues: [String: String] = [:]
|
||
|
||
mutating func setBool(_ key: String, _ value: Bool) {
|
||
boolValues[key] = value
|
||
}
|
||
|
||
mutating func setString(_ key: String, _ value: String?) {
|
||
guard let value, !value.isEmpty else {
|
||
stringValues.removeValue(forKey: key)
|
||
return
|
||
}
|
||
stringValues[key] = value
|
||
}
|
||
|
||
func bool(_ key: String) -> Bool {
|
||
boolValues[key] ?? false
|
||
}
|
||
|
||
func string(_ key: String) -> String? {
|
||
stringValues[key]
|
||
}
|
||
}
|
||
|
||
private enum CommandPaletteContextKeys {
|
||
static let hasWorkspace = "workspace.hasSelection"
|
||
static let workspaceName = "workspace.name"
|
||
static let workspaceHasCustomName = "workspace.hasCustomName"
|
||
static let workspaceShouldPin = "workspace.shouldPin"
|
||
static let workspaceHasPullRequests = "workspace.hasPullRequests"
|
||
static let workspaceHasSplits = "workspace.hasSplits"
|
||
|
||
static let hasFocusedPanel = "panel.hasFocus"
|
||
static let panelName = "panel.name"
|
||
static let panelIsBrowser = "panel.isBrowser"
|
||
static let panelIsTerminal = "panel.isTerminal"
|
||
static let panelHasCustomName = "panel.hasCustomName"
|
||
static let panelShouldPin = "panel.shouldPin"
|
||
static let panelHasUnread = "panel.hasUnread"
|
||
|
||
static let updateHasAvailable = "update.hasAvailable"
|
||
|
||
static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String {
|
||
"terminal.openTarget.\(target.rawValue).available"
|
||
}
|
||
}
|
||
|
||
private struct CommandPaletteCommandContribution {
|
||
let commandId: String
|
||
let title: (CommandPaletteContextSnapshot) -> String
|
||
let subtitle: (CommandPaletteContextSnapshot) -> String
|
||
let shortcutHint: String?
|
||
let keywords: [String]
|
||
let dismissOnRun: Bool
|
||
let when: (CommandPaletteContextSnapshot) -> Bool
|
||
let enablement: (CommandPaletteContextSnapshot) -> Bool
|
||
|
||
init(
|
||
commandId: String,
|
||
title: @escaping (CommandPaletteContextSnapshot) -> String,
|
||
subtitle: @escaping (CommandPaletteContextSnapshot) -> String,
|
||
shortcutHint: String? = nil,
|
||
keywords: [String] = [],
|
||
dismissOnRun: Bool = true,
|
||
when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true },
|
||
enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }
|
||
) {
|
||
self.commandId = commandId
|
||
self.title = title
|
||
self.subtitle = subtitle
|
||
self.shortcutHint = shortcutHint
|
||
self.keywords = keywords
|
||
self.dismissOnRun = dismissOnRun
|
||
self.when = when
|
||
self.enablement = enablement
|
||
}
|
||
}
|
||
|
||
private struct CommandPaletteHandlerRegistry {
|
||
private var handlers: [String: () -> Void] = [:]
|
||
|
||
mutating func register(commandId: String, handler: @escaping () -> Void) {
|
||
handlers[commandId] = handler
|
||
}
|
||
|
||
func handler(for commandId: String) -> (() -> Void)? {
|
||
handlers[commandId]
|
||
}
|
||
}
|
||
|
||
private struct CommandPaletteSearchResult: Identifiable {
|
||
let command: CommandPaletteCommand
|
||
let score: Int
|
||
let titleMatchIndices: Set<Int>
|
||
|
||
var id: String { command.id }
|
||
}
|
||
|
||
private struct CommandPaletteSwitcherWindowContext {
|
||
let windowId: UUID
|
||
let tabManager: TabManager
|
||
let selectedWorkspaceId: UUID?
|
||
let windowLabel: String?
|
||
}
|
||
|
||
private static let fixedSidebarResizeCursor = NSCursor(
|
||
image: NSCursor.resizeLeftRight.image,
|
||
hotSpot: NSCursor.resizeLeftRight.hotSpot
|
||
)
|
||
private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1"
|
||
private static let commandPaletteCommandsPrefix = ">"
|
||
private static let minimumSidebarWidth: CGFloat = 186
|
||
private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0
|
||
|
||
private enum SidebarResizerHandle: Hashable {
|
||
case divider
|
||
}
|
||
|
||
private var sidebarResizerHitWidthPerSide: CGFloat {
|
||
SidebarResizeInteraction.hitWidthPerSide
|
||
}
|
||
|
||
private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat {
|
||
let resolvedAvailableWidth = availableWidth
|
||
?? observedWindow?.contentView?.bounds.width
|
||
?? observedWindow?.contentLayoutRect.width
|
||
?? NSApp.keyWindow?.contentView?.bounds.width
|
||
?? NSApp.keyWindow?.contentLayoutRect.width
|
||
if let resolvedAvailableWidth, resolvedAvailableWidth > 0 {
|
||
return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio)
|
||
}
|
||
|
||
let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width
|
||
?? NSScreen.main?.frame.width
|
||
?? 1920
|
||
return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio)
|
||
}
|
||
|
||
private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) {
|
||
let nextWidth = max(
|
||
Self.minimumSidebarWidth,
|
||
min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth)
|
||
)
|
||
guard abs(nextWidth - sidebarWidth) > 0.5 else { return }
|
||
withTransaction(Transaction(animation: nil)) {
|
||
sidebarWidth = nextWidth
|
||
}
|
||
}
|
||
|
||
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,
|
||
availableWidth: 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(
|
||
Self.minimumSidebarWidth,
|
||
min(maxSidebarWidth(availableWidth: availableWidth), 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,
|
||
availableWidth: totalWidth,
|
||
accessibilityIdentifier: "SidebarResizer"
|
||
)
|
||
|
||
Color.clear
|
||
.frame(maxWidth: .infinity)
|
||
.allowsHitTesting(false)
|
||
}
|
||
.frame(width: totalWidth, height: proxy.size.height, alignment: .leading)
|
||
.onAppear {
|
||
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
|
||
}
|
||
.onChange(of: totalWidth) {
|
||
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
// Keep the retiring workspace visible during handoff, but never input-active.
|
||
// Allowing both selected+retiring workspaces to be input-active lets the
|
||
// old workspace steal first responder (notably with WKWebView), which can
|
||
// delay handoff completion and make browser returns feel laggy.
|
||
let isInputActive = isSelectedWorkspace
|
||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||
WorkspaceContentView(
|
||
workspace: tab,
|
||
isWorkspaceVisible: isVisible,
|
||
isWorkspaceInputActive: isInputActive,
|
||
workspacePortalPriority: portalPriority,
|
||
onThemeRefreshRequest: { reason, eventId, source, payloadHex in
|
||
scheduleTitlebarThemeRefreshFromWorkspace(
|
||
workspaceId: tab.id,
|
||
reason: reason,
|
||
backgroundEventId: eventId,
|
||
backgroundSource: source,
|
||
notificationPayloadHex: payloadHex
|
||
)
|
||
}
|
||
)
|
||
.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 = false
|
||
@AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0
|
||
|
||
@State private var titlebarLeadingInset: CGFloat = 12
|
||
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
|
||
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: { 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)
|
||
.allowsHitTesting(false)
|
||
|
||
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)
|
||
.allowsHitTesting(false)
|
||
|
||
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())
|
||
.background({
|
||
// The terminal area has two stacked semi-transparent layers: the Bonsplit
|
||
// container chrome background plus Ghostty's own Metal-rendered background.
|
||
// Compute the effective composited opacity so the titlebar matches visually.
|
||
let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity)
|
||
let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2)
|
||
return TitlebarLayerBackground(
|
||
backgroundColor: GhosttyApp.shared.defaultBackgroundColor,
|
||
opacity: effective
|
||
)
|
||
}())
|
||
.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(
|
||
reason: String,
|
||
backgroundEventId: UInt64? = nil,
|
||
backgroundSource: String? = nil,
|
||
notificationPayloadHex: String? = nil
|
||
) {
|
||
let previousGeneration = titlebarThemeGeneration
|
||
titlebarThemeGeneration &+= 1
|
||
if GhosttyApp.shared.backgroundLogEnabled {
|
||
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
|
||
let sourceLabel = backgroundSource ?? "nil"
|
||
let payloadLabel = notificationPayloadHex ?? "nil"
|
||
GhosttyApp.shared.logBackground(
|
||
"titlebar theme refresh scheduled reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousGeneration=\(previousGeneration) generation=\(titlebarThemeGeneration) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
|
||
)
|
||
}
|
||
}
|
||
|
||
private func scheduleTitlebarThemeRefreshFromWorkspace(
|
||
workspaceId: UUID,
|
||
reason: String,
|
||
backgroundEventId: UInt64?,
|
||
backgroundSource: String?,
|
||
notificationPayloadHex: String?
|
||
) {
|
||
guard tabManager.selectedTabId == workspaceId else {
|
||
guard GhosttyApp.shared.backgroundLogEnabled else { return }
|
||
GhosttyApp.shared.logBackground(
|
||
"titlebar theme refresh skipped workspace=\(workspaceId.uuidString) selected=\(tabManager.selectedTabId?.uuidString ?? "nil") reason=\(reason)"
|
||
)
|
||
return
|
||
}
|
||
|
||
scheduleTitlebarThemeRefresh(
|
||
reason: reason,
|
||
backgroundEventId: backgroundEventId,
|
||
backgroundSource: backgroundSource,
|
||
notificationPayloadHex: notificationPayloadHex
|
||
)
|
||
}
|
||
|
||
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
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.overlay(alignment: .topLeading) {
|
||
if isFullScreen && sidebarState.isVisible {
|
||
fullscreenControls
|
||
.padding(.leading, 10)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
.frame(minWidth: CGFloat(SessionPersistencePolicy.minimumWindowWidth), minHeight: CGFloat(SessionPersistencePolicy.minimumWindowHeight))
|
||
.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()
|
||
|
||
// Startup recovery (#399): if session restore or a race condition leaves the
|
||
// view in a broken state (empty tabs, no selection, unmounted workspaces),
|
||
// detect and recover after a short delay.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak tabManager] in
|
||
guard let tabManager else { return }
|
||
var didRecover = false
|
||
|
||
// Ensure there is at least one workspace.
|
||
if tabManager.tabs.isEmpty {
|
||
tabManager.addWorkspace()
|
||
didRecover = true
|
||
}
|
||
|
||
// Ensure selectedTabId points to an existing workspace.
|
||
if tabManager.selectedTabId == nil || !tabManager.tabs.contains(where: { $0.id == tabManager.selectedTabId }) {
|
||
tabManager.selectedTabId = tabManager.tabs.first?.id
|
||
didRecover = true
|
||
}
|
||
|
||
// Ensure mountedWorkspaceIds is populated.
|
||
if mountedWorkspaceIds.isEmpty || !mountedWorkspaceIds.contains(where: { id in tabManager.tabs.contains { $0.id == id } }) {
|
||
reconcileMountedWorkspaceIds()
|
||
didRecover = true
|
||
}
|
||
|
||
// Ensure sidebar selection is valid.
|
||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
didRecover = true
|
||
}
|
||
|
||
if didRecover {
|
||
#if DEBUG
|
||
dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)")
|
||
#endif
|
||
sentryBreadcrumb("startup.recovery", data: [
|
||
"tabCount": tabManager.tabs.count,
|
||
"selectedTabId": tabManager.selectedTabId?.uuidString ?? "nil",
|
||
"mountedCount": mountedWorkspaceIds.count
|
||
])
|
||
}
|
||
}
|
||
})
|
||
|
||
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.onChange(of: titlebarThemeGeneration) { oldValue, newValue in
|
||
guard GhosttyApp.shared.backgroundLogEnabled else { return }
|
||
GhosttyApp.shared.logBackground(
|
||
"titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
|
||
)
|
||
})
|
||
|
||
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(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in
|
||
guard let webView = notification.object as? WKWebView,
|
||
let selectedTabId = tabManager.selectedTabId,
|
||
let selectedWorkspace = tabManager.selectedWorkspace,
|
||
let focusedPanelId = selectedWorkspace.focusedPanelId,
|
||
let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId),
|
||
focusedBrowser.webView === webView else { return }
|
||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder")
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in
|
||
guard let panelId = notification.object as? UUID,
|
||
let selectedTabId = tabManager.selectedTabId,
|
||
let selectedWorkspace = tabManager.selectedWorkspace,
|
||
selectedWorkspace.focusedPanelId == panelId,
|
||
selectedWorkspace.browserPanel(for: panelId) != nil else { return }
|
||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar")
|
||
})
|
||
|
||
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.onReceive(NotificationCenter.default.publisher(for: .commandPaletteToggleRequested)) { notification in
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
toggleCommandPalette()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRequested)) { notification in
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
openCommandPaletteCommands()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSwitcherRequested)) { notification in
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
openCommandPaletteSwitcher()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
openCommandPaletteRenameTabInput()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameWorkspaceRequested)) { notification in
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
openCommandPaletteRenameWorkspaceInput()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in
|
||
guard isCommandPalettePresented else { return }
|
||
guard case .commands = commandPaletteMode else { return }
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
|
||
moveCommandPaletteSelection(by: delta)
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputInteractionRequested)) { notification in
|
||
guard isCommandPalettePresented else { return }
|
||
guard case .renameInput = commandPaletteMode else { return }
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
handleCommandPaletteRenameInputInteraction()
|
||
})
|
||
|
||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputDeleteBackwardRequested)) { notification in
|
||
guard isCommandPalettePresented else { return }
|
||
guard case .renameInput = commandPaletteMode else { return }
|
||
let requestedWindow = notification.object as? NSWindow
|
||
guard Self.shouldHandleCommandPaletteRequest(
|
||
observedWindow: observedWindow,
|
||
requestedWindow: requestedWindow,
|
||
keyWindow: NSApp.keyWindow,
|
||
mainWindow: NSApp.mainWindow
|
||
) else { return }
|
||
_ = handleCommandPaletteRenameDeleteBackward(modifiers: [])
|
||
})
|
||
|
||
view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in
|
||
MainActor.assumeIsolated {
|
||
let overlayController = commandPaletteWindowOverlayController(for: window)
|
||
overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented)
|
||
}
|
||
}))
|
||
|
||
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.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in
|
||
guard let window = notification.object as? NSWindow,
|
||
window === observedWindow else { return }
|
||
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
|
||
updateSidebarResizerBandState()
|
||
})
|
||
|
||
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
|
||
// Keep the window immovable by default so titlebar controls (like the folder icon)
|
||
// cannot accidentally initiate native window drags.
|
||
window.isMovable = false
|
||
window.styleMask.insert(.fullSizeContentView)
|
||
|
||
// Track this window for fullscreen notifications
|
||
if observedWindow !== window {
|
||
DispatchQueue.main.async {
|
||
observedWindow = window
|
||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
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.
|
||
let currentThemeBackground = GhosttyBackgroundTheme.currentColor()
|
||
let shouldApplyWindowGlassFallback =
|
||
sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue
|
||
&& bgGlassEnabled
|
||
&& !WindowGlassEffect.isAvailable
|
||
let shouldForceTransparentHosting =
|
||
shouldApplyWindowGlassFallback || currentThemeBackground.alphaComponent < 0.999
|
||
|
||
if shouldForceTransparentHosting {
|
||
window.isOpaque = false
|
||
// Keep the window clear whenever translucency is active. Relying only on
|
||
// terminal focus-driven updates can leave stale opaque window fills.
|
||
window.backgroundColor = NSColor.white.withAlphaComponent(0.001)
|
||
// Configure contentView hierarchy for transparency.
|
||
if let contentView = window.contentView {
|
||
makeViewHierarchyTransparent(contentView)
|
||
}
|
||
} else {
|
||
// Browser-focused workspaces may not have an active terminal panel to refresh
|
||
// the NSWindow background. Keep opaque theme changes applied here as well.
|
||
window.backgroundColor = currentThemeBackground
|
||
window.isOpaque = currentThemeBackground.alphaComponent >= 0.999
|
||
}
|
||
|
||
if shouldApplyWindowGlassFallback {
|
||
// 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 makeViewHierarchyTransparent(_ root: NSView) {
|
||
var stack: [NSView] = [root]
|
||
while let view = stack.popLast() {
|
||
view.wantsLayer = true
|
||
view.layer?.backgroundColor = NSColor.clear.cgColor
|
||
view.layer?.isOpaque = false
|
||
stack.append(contentsOf: view.subviews)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
private var commandPaletteOverlay: some View {
|
||
GeometryReader { proxy in
|
||
let maxAllowedWidth = max(340, proxy.size.width - 260)
|
||
let targetWidth = min(560, maxAllowedWidth)
|
||
|
||
ZStack(alignment: .top) {
|
||
Color.clear
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
dismissCommandPalette()
|
||
}
|
||
|
||
VStack(spacing: 0) {
|
||
switch commandPaletteMode {
|
||
case .commands:
|
||
commandPaletteCommandListView
|
||
case .renameInput(let target):
|
||
commandPaletteRenameInputView(target: target)
|
||
case let .renameConfirm(target, proposedName):
|
||
commandPaletteRenameConfirmView(target: target, proposedName: proposedName)
|
||
}
|
||
}
|
||
.frame(width: targetWidth)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.98))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)
|
||
)
|
||
.shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5)
|
||
.padding(.top, 40)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.onExitCommand {
|
||
dismissCommandPalette()
|
||
}
|
||
.zIndex(2000)
|
||
}
|
||
|
||
private var commandPaletteCommandListView: some View {
|
||
let visibleResults = Array(commandPaletteResults)
|
||
let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||
let commandPaletteListMaxHeight: CGFloat = 450
|
||
let commandPaletteRowHeight: CGFloat = 24
|
||
let commandPaletteEmptyStateHeight: CGFloat = 44
|
||
let commandPaletteListContentHeight = visibleResults.isEmpty
|
||
? commandPaletteEmptyStateHeight
|
||
: CGFloat(visibleResults.count) * commandPaletteRowHeight
|
||
let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight)
|
||
return VStack(spacing: 0) {
|
||
HStack(spacing: 8) {
|
||
TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 13, weight: .regular))
|
||
.tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)))
|
||
.focused($isCommandPaletteSearchFocused)
|
||
.onSubmit {
|
||
runSelectedCommandPaletteResult(visibleResults: visibleResults)
|
||
}
|
||
.backport.onKeyPress(.downArrow) { _ in
|
||
moveCommandPaletteSelection(by: 1)
|
||
return .handled
|
||
}
|
||
.backport.onKeyPress(.upArrow) { _ in
|
||
moveCommandPaletteSelection(by: -1)
|
||
return .handled
|
||
}
|
||
.backport.onKeyPress("n") { modifiers in
|
||
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1)
|
||
}
|
||
.backport.onKeyPress("p") { modifiers in
|
||
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1)
|
||
}
|
||
.backport.onKeyPress("j") { modifiers in
|
||
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1)
|
||
}
|
||
.backport.onKeyPress("k") { modifiers in
|
||
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1)
|
||
}
|
||
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 7)
|
||
|
||
Divider()
|
||
|
||
ScrollView {
|
||
LazyVStack(spacing: 0) {
|
||
if visibleResults.isEmpty {
|
||
Text(commandPaletteEmptyStateText)
|
||
.font(.system(size: 13, weight: .regular))
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 12)
|
||
} else {
|
||
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
|
||
let isSelected = index == selectedIndex
|
||
let isHovered = commandPaletteHoveredResultIndex == index
|
||
let rowBackground: Color = isSelected
|
||
? cmuxAccentColor().opacity(0.12)
|
||
: (isHovered ? Color.primary.opacity(0.08) : .clear)
|
||
|
||
Button {
|
||
runCommandPaletteCommand(result.command)
|
||
} label: {
|
||
HStack(spacing: 8) {
|
||
commandPaletteHighlightedTitleText(
|
||
result.command.title,
|
||
matchedIndices: result.titleMatchIndices
|
||
)
|
||
.font(.system(size: 13, weight: .regular))
|
||
.lineLimit(1)
|
||
Spacer()
|
||
|
||
if let trailingLabel = commandPaletteTrailingLabel(for: result.command) {
|
||
switch trailingLabel.style {
|
||
case .shortcut:
|
||
Text(trailingLabel.text)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 4)
|
||
.padding(.vertical, 1)
|
||
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||
case .kind:
|
||
Text(trailingLabel.text)
|
||
.font(.system(size: 11, weight: .regular))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 2)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(rowBackground)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.id(index)
|
||
.onHover { hovering in
|
||
if hovering {
|
||
commandPaletteHoveredResultIndex = index
|
||
} else if commandPaletteHoveredResultIndex == index {
|
||
commandPaletteHoveredResultIndex = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.scrollTargetLayout()
|
||
// Force a fresh row tree per query so rendered labels/actions stay in lockstep.
|
||
.id(commandPaletteQuery)
|
||
}
|
||
.frame(height: commandPaletteListHeight)
|
||
.scrollPosition(
|
||
id: Binding(
|
||
get: { commandPaletteScrollTargetIndex },
|
||
// Ignore passive readback so manual scrolling doesn't mutate selection-follow state.
|
||
set: { _ in }
|
||
),
|
||
anchor: commandPaletteScrollTargetAnchor
|
||
)
|
||
.onChange(of: commandPaletteSelectedResultIndex) { _ in
|
||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true)
|
||
}
|
||
|
||
// Keep Esc-to-close behavior without showing footer controls.
|
||
Button(action: { dismissCommandPalette() }) {
|
||
EmptyView()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.keyboardShortcut(.cancelAction)
|
||
.frame(width: 0, height: 0)
|
||
.opacity(0)
|
||
.accessibilityHidden(true)
|
||
}
|
||
.onAppear {
|
||
commandPaletteHoveredResultIndex = nil
|
||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
|
||
resetCommandPaletteSearchFocus()
|
||
}
|
||
.onChange(of: commandPaletteQuery) { _ in
|
||
commandPaletteSelectedResultIndex = 0
|
||
commandPaletteHoveredResultIndex = nil
|
||
commandPaletteScrollTargetIndex = nil
|
||
commandPaletteScrollTargetAnchor = nil
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
.onChange(of: visibleResults.count) { _ in
|
||
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
|
||
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count {
|
||
commandPaletteHoveredResultIndex = nil
|
||
}
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
.onChange(of: commandPaletteSelectedResultIndex) { _ in
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
}
|
||
|
||
private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View {
|
||
VStack(spacing: 0) {
|
||
TextField(target.placeholder, text: $commandPaletteRenameDraft)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 13, weight: .regular))
|
||
.tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)))
|
||
.focused($isCommandPaletteRenameFocused)
|
||
.backport.onKeyPress(.delete) { modifiers in
|
||
handleCommandPaletteRenameDeleteBackward(modifiers: modifiers)
|
||
}
|
||
.onSubmit {
|
||
continueRenameFlow(target: target)
|
||
}
|
||
.onTapGesture {
|
||
handleCommandPaletteRenameInputInteraction()
|
||
}
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 7)
|
||
|
||
Divider()
|
||
|
||
Text("Enter a \(renameTargetNoun(target)) name. Press Enter to rename, Escape to cancel.")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 6)
|
||
|
||
Button(action: {
|
||
continueRenameFlow(target: target)
|
||
}) {
|
||
EmptyView()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.keyboardShortcut(.defaultAction)
|
||
.frame(width: 0, height: 0)
|
||
.opacity(0)
|
||
.accessibilityHidden(true)
|
||
}
|
||
.onAppear {
|
||
resetCommandPaletteRenameFocus()
|
||
}
|
||
}
|
||
|
||
private func commandPaletteRenameConfirmView(
|
||
target: CommandPaletteRenameTarget,
|
||
proposedName: String
|
||
) -> some View {
|
||
let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let nextName = trimmedName.isEmpty ? "(clear custom name)" : trimmedName
|
||
|
||
return VStack(spacing: 0) {
|
||
Text(nextName)
|
||
.font(.system(size: 13, weight: .regular))
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 7)
|
||
|
||
Divider()
|
||
|
||
Text("Press Enter to apply this \(renameTargetNoun(target)) name, or Escape to cancel.")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal, 9)
|
||
.padding(.vertical, 6)
|
||
|
||
Button(action: {
|
||
applyRenameFlow(target: target, proposedName: proposedName)
|
||
}) {
|
||
EmptyView()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.keyboardShortcut(.defaultAction)
|
||
.frame(width: 0, height: 0)
|
||
.opacity(0)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
private func renameTargetNoun(_ target: CommandPaletteRenameTarget) -> String {
|
||
switch target.kind {
|
||
case .workspace:
|
||
return "workspace"
|
||
case .tab:
|
||
return "tab"
|
||
}
|
||
}
|
||
|
||
private var commandPaletteListScope: CommandPaletteListScope {
|
||
if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) {
|
||
return .commands
|
||
}
|
||
return .switcher
|
||
}
|
||
|
||
private var commandPaletteSearchPlaceholder: String {
|
||
switch commandPaletteListScope {
|
||
case .commands:
|
||
return "Type a command"
|
||
case .switcher:
|
||
return "Search workspaces and tabs"
|
||
}
|
||
}
|
||
|
||
private var commandPaletteEmptyStateText: String {
|
||
switch commandPaletteListScope {
|
||
case .commands:
|
||
return "No commands match your search."
|
||
case .switcher:
|
||
return "No workspaces or tabs match your search."
|
||
}
|
||
}
|
||
|
||
private var commandPaletteQueryForMatching: String {
|
||
switch commandPaletteListScope {
|
||
case .commands:
|
||
let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count))
|
||
return suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
case .switcher:
|
||
return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
}
|
||
|
||
private var commandPaletteEntries: [CommandPaletteCommand] {
|
||
switch commandPaletteListScope {
|
||
case .commands:
|
||
return commandPaletteCommands()
|
||
case .switcher:
|
||
return commandPaletteSwitcherEntries()
|
||
}
|
||
}
|
||
|
||
private var commandPaletteResults: [CommandPaletteSearchResult] {
|
||
let entries = commandPaletteEntries
|
||
let query = commandPaletteQueryForMatching
|
||
let queryIsEmpty = query.isEmpty
|
||
|
||
let results: [CommandPaletteSearchResult] = queryIsEmpty
|
||
? entries.map { entry in
|
||
CommandPaletteSearchResult(
|
||
command: entry,
|
||
score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true),
|
||
titleMatchIndices: []
|
||
)
|
||
}
|
||
: entries.compactMap { entry in
|
||
guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else {
|
||
return nil
|
||
}
|
||
return CommandPaletteSearchResult(
|
||
command: entry,
|
||
score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false),
|
||
titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||
query: query,
|
||
candidate: entry.title
|
||
)
|
||
)
|
||
}
|
||
|
||
return results
|
||
.sorted { lhs, rhs in
|
||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
||
if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank }
|
||
return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending
|
||
}
|
||
}
|
||
|
||
private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set<Int>) -> Text {
|
||
guard !matchedIndices.isEmpty else {
|
||
return Text(title).foregroundColor(.primary)
|
||
}
|
||
|
||
let chars = Array(title)
|
||
var index = 0
|
||
var result = Text("")
|
||
|
||
while index < chars.count {
|
||
let isMatched = matchedIndices.contains(index)
|
||
var end = index + 1
|
||
while end < chars.count, matchedIndices.contains(end) == isMatched {
|
||
end += 1
|
||
}
|
||
|
||
let segment = String(chars[index..<end])
|
||
if isMatched {
|
||
result = result + Text(segment).foregroundColor(.blue)
|
||
} else {
|
||
result = result + Text(segment).foregroundColor(.primary)
|
||
}
|
||
index = end
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
private func commandPaletteTrailingLabel(for command: CommandPaletteCommand) -> CommandPaletteTrailingLabel? {
|
||
if let shortcutHint = command.shortcutHint {
|
||
return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut)
|
||
}
|
||
|
||
guard commandPaletteListScope == .switcher else { return nil }
|
||
if command.id.hasPrefix("switcher.workspace.") {
|
||
return CommandPaletteTrailingLabel(text: "Workspace", style: .kind)
|
||
}
|
||
if command.id.hasPrefix("switcher.surface.") {
|
||
return CommandPaletteTrailingLabel(text: "Surface", style: .kind)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] {
|
||
let windowContexts = commandPaletteSwitcherWindowContexts()
|
||
guard !windowContexts.isEmpty else { return [] }
|
||
|
||
var entries: [CommandPaletteCommand] = []
|
||
let estimatedCount = windowContexts.reduce(0) { partial, context in
|
||
partial + max(1, context.tabManager.tabs.count) * 4
|
||
}
|
||
entries.reserveCapacity(estimatedCount)
|
||
var nextRank = 0
|
||
|
||
for context in windowContexts {
|
||
var workspaces = context.tabManager.tabs
|
||
guard !workspaces.isEmpty else { continue }
|
||
|
||
let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId
|
||
if let selectedWorkspaceId,
|
||
let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) {
|
||
let selectedWorkspace = workspaces.remove(at: selectedIndex)
|
||
workspaces.insert(selectedWorkspace, at: 0)
|
||
}
|
||
|
||
let windowId = context.windowId
|
||
let windowTabManager = context.tabManager
|
||
let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel)
|
||
for workspace in workspaces {
|
||
let workspaceName = workspaceDisplayName(workspace)
|
||
let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())"
|
||
let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||
baseKeywords: [
|
||
"workspace",
|
||
"switch",
|
||
"go",
|
||
"open",
|
||
workspaceName
|
||
] + windowKeywords,
|
||
metadata: commandPaletteWorkspaceSearchMetadata(for: workspace),
|
||
detail: .workspace
|
||
)
|
||
let workspaceId = workspace.id
|
||
entries.append(
|
||
CommandPaletteCommand(
|
||
id: workspaceCommandId,
|
||
rank: nextRank,
|
||
title: workspaceName,
|
||
subtitle: commandPaletteSwitcherSubtitle(base: "Workspace", windowLabel: context.windowLabel),
|
||
shortcutHint: nil,
|
||
keywords: workspaceKeywords,
|
||
dismissOnRun: true,
|
||
action: {
|
||
focusCommandPaletteSwitcherTarget(
|
||
windowId: windowId,
|
||
tabManager: windowTabManager,
|
||
workspaceId: workspaceId,
|
||
panelId: nil
|
||
)
|
||
}
|
||
)
|
||
)
|
||
nextRank += 1
|
||
|
||
var orderedPanelIds = workspace.sidebarOrderedPanelIds()
|
||
if let focusedPanelId = workspace.focusedPanelId,
|
||
let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) {
|
||
orderedPanelIds.remove(at: focusedIndex)
|
||
orderedPanelIds.insert(focusedPanelId, at: 0)
|
||
}
|
||
|
||
for panelId in orderedPanelIds {
|
||
guard let panel = workspace.panels[panelId] else { continue }
|
||
let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle)
|
||
let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal"
|
||
let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||
baseKeywords: [
|
||
"tab",
|
||
"surface",
|
||
"panel",
|
||
"switch",
|
||
"go",
|
||
workspaceName,
|
||
panelTitle,
|
||
typeLabel.lowercased()
|
||
] + windowKeywords,
|
||
metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId)
|
||
)
|
||
entries.append(
|
||
CommandPaletteCommand(
|
||
id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())",
|
||
rank: nextRank,
|
||
title: panelTitle,
|
||
subtitle: commandPaletteSwitcherSubtitle(
|
||
base: "\(typeLabel) • \(workspaceName)",
|
||
windowLabel: context.windowLabel
|
||
),
|
||
shortcutHint: nil,
|
||
keywords: panelKeywords,
|
||
dismissOnRun: true,
|
||
action: {
|
||
focusCommandPaletteSwitcherTarget(
|
||
windowId: windowId,
|
||
tabManager: windowTabManager,
|
||
workspaceId: workspaceId,
|
||
panelId: panelId
|
||
)
|
||
}
|
||
)
|
||
)
|
||
nextRank += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
return entries
|
||
}
|
||
|
||
private func commandPaletteSwitcherWindowContexts() -> [CommandPaletteSwitcherWindowContext] {
|
||
let fallback = CommandPaletteSwitcherWindowContext(
|
||
windowId: windowId,
|
||
tabManager: tabManager,
|
||
selectedWorkspaceId: tabManager.selectedTabId,
|
||
windowLabel: nil
|
||
)
|
||
|
||
guard let appDelegate = AppDelegate.shared else { return [fallback] }
|
||
let summaries = appDelegate.listMainWindowSummaries()
|
||
guard !summaries.isEmpty else { return [fallback] }
|
||
|
||
let orderedSummaries = summaries.sorted { lhs, rhs in
|
||
let lhsIsCurrent = lhs.windowId == windowId
|
||
let rhsIsCurrent = rhs.windowId == windowId
|
||
if lhsIsCurrent != rhsIsCurrent { return lhsIsCurrent }
|
||
if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow }
|
||
if lhs.isVisible != rhs.isVisible { return lhs.isVisible }
|
||
return lhs.windowId.uuidString < rhs.windowId.uuidString
|
||
}
|
||
|
||
var windowLabelById: [UUID: String] = [:]
|
||
if orderedSummaries.count > 1 {
|
||
for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId {
|
||
windowLabelById[summary.windowId] = "Window \(index + 1)"
|
||
}
|
||
}
|
||
|
||
var contexts: [CommandPaletteSwitcherWindowContext] = []
|
||
var seenWindowIds: Set<UUID> = []
|
||
for summary in orderedSummaries {
|
||
guard let manager = appDelegate.tabManagerFor(windowId: summary.windowId) else { continue }
|
||
guard seenWindowIds.insert(summary.windowId).inserted else { continue }
|
||
contexts.append(
|
||
CommandPaletteSwitcherWindowContext(
|
||
windowId: summary.windowId,
|
||
tabManager: manager,
|
||
selectedWorkspaceId: summary.selectedWorkspaceId,
|
||
windowLabel: windowLabelById[summary.windowId]
|
||
)
|
||
)
|
||
}
|
||
|
||
if contexts.isEmpty {
|
||
return [fallback]
|
||
}
|
||
return contexts
|
||
}
|
||
|
||
private func commandPaletteSwitcherSubtitle(base: String, windowLabel: String?) -> String {
|
||
guard let windowLabel else { return base }
|
||
return "\(base) • \(windowLabel)"
|
||
}
|
||
|
||
private func commandPaletteWindowKeywords(windowLabel: String?) -> [String] {
|
||
guard let windowLabel else { return [] }
|
||
return ["window", windowLabel.lowercased()]
|
||
}
|
||
|
||
private func focusCommandPaletteSwitcherTarget(
|
||
windowId: UUID,
|
||
tabManager: TabManager,
|
||
workspaceId: UUID,
|
||
panelId: UUID?
|
||
) {
|
||
// Switcher commands dismiss the palette after action dispatch.
|
||
// Defer focus mutation one turn so browser omnibar autofocus can run
|
||
// without being blocked by the palette-visibility guard.
|
||
DispatchQueue.main.async {
|
||
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
|
||
if let panelId {
|
||
tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true)
|
||
} else {
|
||
tabManager.focusTab(workspaceId, suppressFlash: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata {
|
||
// Keep workspace rows coarse so surface rows win for directory/branch-specific queries.
|
||
let directories = [workspace.currentDirectory]
|
||
let branches = [workspace.gitBranch?.branch].compactMap { $0 }
|
||
let ports = workspace.listeningPorts
|
||
return CommandPaletteSwitcherSearchMetadata(
|
||
directories: directories,
|
||
branches: branches,
|
||
ports: ports
|
||
)
|
||
}
|
||
|
||
private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata {
|
||
var directories: [String] = []
|
||
if let directory = workspace.panelDirectories[panelId] {
|
||
directories.append(directory)
|
||
} else if workspace.focusedPanelId == panelId {
|
||
directories.append(workspace.currentDirectory)
|
||
}
|
||
|
||
var branches: [String] = []
|
||
if let branch = workspace.panelGitBranches[panelId]?.branch {
|
||
branches.append(branch)
|
||
} else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch {
|
||
branches.append(branch)
|
||
}
|
||
|
||
var ports = workspace.surfaceListeningPorts[panelId] ?? []
|
||
if ports.isEmpty, workspace.panels.count == 1 {
|
||
ports = workspace.listeningPorts
|
||
}
|
||
|
||
return CommandPaletteSwitcherSearchMetadata(
|
||
directories: directories,
|
||
branches: branches,
|
||
ports: ports
|
||
)
|
||
}
|
||
|
||
private func commandPaletteCommands() -> [CommandPaletteCommand] {
|
||
let context = commandPaletteContextSnapshot()
|
||
let contributions = commandPaletteCommandContributions()
|
||
var handlerRegistry = CommandPaletteHandlerRegistry()
|
||
registerCommandPaletteHandlers(&handlerRegistry)
|
||
|
||
var commands: [CommandPaletteCommand] = []
|
||
commands.reserveCapacity(contributions.count)
|
||
var nextRank = 0
|
||
|
||
for contribution in contributions {
|
||
guard contribution.when(context), contribution.enablement(context) else { continue }
|
||
guard let action = handlerRegistry.handler(for: contribution.commandId) else {
|
||
assertionFailure("No command palette handler registered for \(contribution.commandId)")
|
||
continue
|
||
}
|
||
commands.append(
|
||
CommandPaletteCommand(
|
||
id: contribution.commandId,
|
||
rank: nextRank,
|
||
title: contribution.title(context),
|
||
subtitle: contribution.subtitle(context),
|
||
shortcutHint: commandPaletteShortcutHint(for: contribution, context: context),
|
||
keywords: contribution.keywords,
|
||
dismissOnRun: contribution.dismissOnRun,
|
||
action: action
|
||
)
|
||
)
|
||
nextRank += 1
|
||
}
|
||
|
||
return commands
|
||
}
|
||
|
||
private func commandPaletteShortcutHint(
|
||
for contribution: CommandPaletteCommandContribution,
|
||
context: CommandPaletteContextSnapshot
|
||
) -> String? {
|
||
// Preserve browser reload semantics for Cmd+R when a browser tab is focused.
|
||
if contribution.commandId == "palette.renameTab",
|
||
context.bool(CommandPaletteContextKeys.panelIsBrowser) {
|
||
return nil
|
||
}
|
||
if let action = commandPaletteShortcutAction(for: contribution.commandId) {
|
||
return KeyboardShortcutSettings.shortcut(for: action).displayString
|
||
}
|
||
if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) {
|
||
return staticShortcut
|
||
}
|
||
return contribution.shortcutHint
|
||
}
|
||
|
||
private func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? {
|
||
switch commandId {
|
||
case "palette.newWorkspace":
|
||
return .newTab
|
||
case "palette.newWindow":
|
||
return .newWindow
|
||
case "palette.openFolder":
|
||
return .openFolder
|
||
case "palette.newTerminalTab":
|
||
return .newSurface
|
||
case "palette.newBrowserTab":
|
||
return .openBrowser
|
||
case "palette.closeWindow":
|
||
return .closeWindow
|
||
case "palette.toggleSidebar":
|
||
return .toggleSidebar
|
||
case "palette.showNotifications":
|
||
return .showNotifications
|
||
case "palette.jumpUnread":
|
||
return .jumpToUnread
|
||
case "palette.renameTab":
|
||
return .renameTab
|
||
case "palette.renameWorkspace":
|
||
return .renameWorkspace
|
||
case "palette.nextWorkspace":
|
||
return .nextSidebarTab
|
||
case "palette.previousWorkspace":
|
||
return .prevSidebarTab
|
||
case "palette.nextTabInPane":
|
||
return .nextSurface
|
||
case "palette.previousTabInPane":
|
||
return .prevSurface
|
||
case "palette.browserToggleDevTools":
|
||
return .toggleBrowserDeveloperTools
|
||
case "palette.browserConsole":
|
||
return .showBrowserJavaScriptConsole
|
||
case "palette.browserSplitRight", "palette.terminalSplitBrowserRight":
|
||
return .splitBrowserRight
|
||
case "palette.browserSplitDown", "palette.terminalSplitBrowserDown":
|
||
return .splitBrowserDown
|
||
case "palette.terminalSplitRight":
|
||
return .splitRight
|
||
case "palette.terminalSplitDown":
|
||
return .splitDown
|
||
case "palette.toggleSplitZoom":
|
||
return .toggleSplitZoom
|
||
case "palette.triggerFlash":
|
||
return .triggerFlash
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func commandPaletteStaticShortcutHint(for commandId: String) -> String? {
|
||
switch commandId {
|
||
case "palette.closeTab":
|
||
return "⌘W"
|
||
case "palette.closeWorkspace":
|
||
return "⌘⇧W"
|
||
case "palette.reopenClosedBrowserTab":
|
||
return "⌘⇧T"
|
||
case "palette.openSettings":
|
||
return "⌘,"
|
||
case "palette.browserBack":
|
||
return "⌘["
|
||
case "palette.browserForward":
|
||
return "⌘]"
|
||
case "palette.browserReload":
|
||
return "⌘R"
|
||
case "palette.browserFocusAddressBar":
|
||
return "⌘L"
|
||
case "palette.browserZoomIn":
|
||
return "⌘="
|
||
case "palette.browserZoomOut":
|
||
return "⌘-"
|
||
case "palette.browserZoomReset":
|
||
return "⌘0"
|
||
case "palette.terminalFind":
|
||
return "⌘F"
|
||
case "palette.terminalFindNext":
|
||
return "⌘G"
|
||
case "palette.terminalFindPrevious":
|
||
return "⌘⇧G"
|
||
case "palette.terminalHideFind":
|
||
return "⌘⇧F"
|
||
case "palette.terminalUseSelectionForFind":
|
||
return "⌘E"
|
||
case "palette.toggleFullScreen":
|
||
return "\u{2303}\u{2318}F"
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func commandPaletteContextSnapshot() -> CommandPaletteContextSnapshot {
|
||
var snapshot = CommandPaletteContextSnapshot()
|
||
|
||
if let workspace = tabManager.selectedWorkspace {
|
||
snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true)
|
||
snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace))
|
||
snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil)
|
||
snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned)
|
||
snapshot.setBool(
|
||
CommandPaletteContextKeys.workspaceHasPullRequests,
|
||
!workspace.sidebarPullRequestsInDisplayOrder().isEmpty
|
||
)
|
||
snapshot.setBool(
|
||
CommandPaletteContextKeys.workspaceHasSplits,
|
||
workspace.bonsplitController.allPaneIds.count > 1
|
||
)
|
||
}
|
||
|
||
if let panelContext = focusedPanelContext {
|
||
let workspace = panelContext.workspace
|
||
let panelId = panelContext.panelId
|
||
let panelIsTerminal = panelContext.panel.panelType == .terminal
|
||
snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true)
|
||
snapshot.setString(
|
||
CommandPaletteContextKeys.panelName,
|
||
panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)
|
||
)
|
||
snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser)
|
||
snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal)
|
||
snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil)
|
||
snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId))
|
||
let hasUnread = workspace.manualUnreadPanelIds.contains(panelId)
|
||
|| notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)
|
||
snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread)
|
||
|
||
if panelIsTerminal {
|
||
let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets
|
||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||
snapshot.setBool(
|
||
CommandPaletteContextKeys.terminalOpenTargetAvailable(target),
|
||
availableTargets.contains(target)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
if case .updateAvailable = updateViewModel.effectiveState {
|
||
snapshot.setBool(CommandPaletteContextKeys.updateHasAvailable, true)
|
||
}
|
||
|
||
return snapshot
|
||
}
|
||
|
||
private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] {
|
||
func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String {
|
||
{ _ in value }
|
||
}
|
||
|
||
func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
|
||
let name = context.string(CommandPaletteContextKeys.workspaceName) ?? "Workspace"
|
||
return "Workspace • \(name)"
|
||
}
|
||
|
||
func panelSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
|
||
let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab"
|
||
return "Tab • \(name)"
|
||
}
|
||
|
||
func browserPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
|
||
let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab"
|
||
return "Browser • \(name)"
|
||
}
|
||
|
||
func terminalPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String {
|
||
let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab"
|
||
return "Terminal • \(name)"
|
||
}
|
||
|
||
var contributions: [CommandPaletteCommandContribution] = []
|
||
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.newWorkspace",
|
||
title: constant("New Workspace"),
|
||
subtitle: constant("Workspace"),
|
||
keywords: ["create", "new", "workspace"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.newWindow",
|
||
title: constant("New Window"),
|
||
subtitle: constant("Window"),
|
||
keywords: ["create", "new", "window"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.installCLI",
|
||
title: constant("Shell Command: Install 'cmux' in PATH"),
|
||
subtitle: constant("CLI"),
|
||
keywords: ["install", "cli", "path", "shell", "command", "symlink"],
|
||
when: { _ in !(AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.uninstallCLI",
|
||
title: constant("Shell Command: Uninstall 'cmux' from PATH"),
|
||
subtitle: constant("CLI"),
|
||
keywords: ["uninstall", "remove", "cli", "path", "shell", "command", "symlink"],
|
||
when: { _ in AppDelegate.shared?.isCmuxCLIInstalledInPATH() ?? false }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.openFolder",
|
||
title: constant("Open Folder…"),
|
||
subtitle: constant("Workspace"),
|
||
keywords: ["open", "folder", "repository", "project", "directory"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.newTerminalTab",
|
||
title: constant("New Tab (Terminal)"),
|
||
subtitle: constant("Tab"),
|
||
shortcutHint: "⌘T",
|
||
keywords: ["new", "terminal", "tab"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.newBrowserTab",
|
||
title: constant("New Tab (Browser)"),
|
||
subtitle: constant("Tab"),
|
||
shortcutHint: "⌘⇧L",
|
||
keywords: ["new", "browser", "tab", "web"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.closeTab",
|
||
title: constant("Close Tab"),
|
||
subtitle: constant("Tab"),
|
||
shortcutHint: "⌘W",
|
||
keywords: ["close", "tab"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.closeWorkspace",
|
||
title: constant("Close Workspace"),
|
||
subtitle: constant("Workspace"),
|
||
shortcutHint: "⌘⇧W",
|
||
keywords: ["close", "workspace"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.closeWindow",
|
||
title: constant("Close Window"),
|
||
subtitle: constant("Window"),
|
||
keywords: ["close", "window"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleFullScreen",
|
||
title: constant("Toggle Full Screen"),
|
||
subtitle: constant("Window"),
|
||
keywords: ["fullscreen", "full", "screen", "window", "toggle"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.reopenClosedBrowserTab",
|
||
title: constant("Reopen Closed Browser Tab"),
|
||
subtitle: constant("Browser"),
|
||
shortcutHint: "⌘⇧T",
|
||
keywords: ["reopen", "closed", "browser"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleSidebar",
|
||
title: constant("Toggle Sidebar"),
|
||
subtitle: constant("Layout"),
|
||
keywords: ["toggle", "sidebar", "layout"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.triggerFlash",
|
||
title: constant("Flash Focused Panel"),
|
||
subtitle: constant("View"),
|
||
keywords: ["flash", "highlight", "focus", "panel"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.showNotifications",
|
||
title: constant("Show Notifications"),
|
||
subtitle: constant("Notifications"),
|
||
keywords: ["notifications", "inbox"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.jumpUnread",
|
||
title: constant("Jump to Latest Unread"),
|
||
subtitle: constant("Notifications"),
|
||
keywords: ["jump", "unread", "notification"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.openSettings",
|
||
title: constant("Open Settings"),
|
||
subtitle: constant("Global"),
|
||
shortcutHint: "⌘,",
|
||
keywords: ["settings", "preferences"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.checkForUpdates",
|
||
title: constant("Check for Updates"),
|
||
subtitle: constant("Global"),
|
||
keywords: ["update", "upgrade", "release"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.applyUpdateIfAvailable",
|
||
title: constant("Apply Update (If Available)"),
|
||
subtitle: constant("Global"),
|
||
keywords: ["apply", "install", "update", "available"],
|
||
when: { $0.bool(CommandPaletteContextKeys.updateHasAvailable) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.attemptUpdate",
|
||
title: constant("Attempt Update"),
|
||
subtitle: constant("Global"),
|
||
keywords: ["attempt", "check", "update", "upgrade", "release"]
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.restartSocketListener",
|
||
title: constant("Restart CLI Listener"),
|
||
subtitle: constant("Global"),
|
||
keywords: ["restart", "socket", "listener", "cli", "cmux", "control"]
|
||
)
|
||
)
|
||
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.renameWorkspace",
|
||
title: constant("Rename Workspace…"),
|
||
subtitle: workspaceSubtitle,
|
||
keywords: ["rename", "workspace", "title"],
|
||
dismissOnRun: false,
|
||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.clearWorkspaceName",
|
||
title: constant("Clear Workspace Name"),
|
||
subtitle: workspaceSubtitle,
|
||
keywords: ["clear", "workspace", "name"],
|
||
when: {
|
||
$0.bool(CommandPaletteContextKeys.hasWorkspace)
|
||
&& $0.bool(CommandPaletteContextKeys.workspaceHasCustomName)
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleWorkspacePin",
|
||
title: { context in
|
||
context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? "Pin Workspace" : "Unpin Workspace"
|
||
},
|
||
subtitle: workspaceSubtitle,
|
||
keywords: ["workspace", "pin", "pinned"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.nextWorkspace",
|
||
title: constant("Next Workspace"),
|
||
subtitle: constant("Workspace Navigation"),
|
||
keywords: ["next", "workspace", "navigate"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.previousWorkspace",
|
||
title: constant("Previous Workspace"),
|
||
subtitle: constant("Workspace Navigation"),
|
||
keywords: ["previous", "workspace", "navigate"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }
|
||
)
|
||
)
|
||
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.renameTab",
|
||
title: constant("Rename Tab…"),
|
||
subtitle: panelSubtitle,
|
||
keywords: ["rename", "tab", "title"],
|
||
dismissOnRun: false,
|
||
when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.clearTabName",
|
||
title: constant("Clear Tab Name"),
|
||
subtitle: panelSubtitle,
|
||
keywords: ["clear", "tab", "name"],
|
||
when: {
|
||
$0.bool(CommandPaletteContextKeys.hasFocusedPanel)
|
||
&& $0.bool(CommandPaletteContextKeys.panelHasCustomName)
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleTabPin",
|
||
title: { context in
|
||
context.bool(CommandPaletteContextKeys.panelShouldPin) ? "Pin Tab" : "Unpin Tab"
|
||
},
|
||
subtitle: panelSubtitle,
|
||
keywords: ["tab", "pin", "pinned"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleTabUnread",
|
||
title: { context in
|
||
context.bool(CommandPaletteContextKeys.panelHasUnread) ? "Mark Tab as Read" : "Mark Tab as Unread"
|
||
},
|
||
subtitle: panelSubtitle,
|
||
keywords: ["tab", "read", "unread", "notification"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.nextTabInPane",
|
||
title: constant("Next Tab in Pane"),
|
||
subtitle: constant("Tab Navigation"),
|
||
keywords: ["next", "tab", "pane"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.previousTabInPane",
|
||
title: constant("Previous Tab in Pane"),
|
||
subtitle: constant("Tab Navigation"),
|
||
keywords: ["previous", "tab", "pane"],
|
||
when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) }
|
||
)
|
||
)
|
||
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.openWorkspacePullRequests",
|
||
title: constant("Open All Workspace PR Links"),
|
||
subtitle: workspaceSubtitle,
|
||
keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"],
|
||
when: {
|
||
$0.bool(CommandPaletteContextKeys.hasWorkspace) &&
|
||
$0.bool(CommandPaletteContextKeys.workspaceHasPullRequests)
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserBack",
|
||
title: constant("Back"),
|
||
subtitle: browserPanelSubtitle,
|
||
shortcutHint: "⌘[",
|
||
keywords: ["browser", "back", "history"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserForward",
|
||
title: constant("Forward"),
|
||
subtitle: browserPanelSubtitle,
|
||
shortcutHint: "⌘]",
|
||
keywords: ["browser", "forward", "history"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserReload",
|
||
title: constant("Reload Page"),
|
||
subtitle: browserPanelSubtitle,
|
||
shortcutHint: "⌘R",
|
||
keywords: ["browser", "reload", "refresh"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserOpenDefault",
|
||
title: constant("Open Current Page in Default Browser"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["open", "default", "external", "browser"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserFocusAddressBar",
|
||
title: constant("Focus Address Bar"),
|
||
subtitle: browserPanelSubtitle,
|
||
shortcutHint: "⌘L",
|
||
keywords: ["browser", "address", "omnibar", "url"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserToggleDevTools",
|
||
title: constant("Toggle Developer Tools"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["browser", "devtools", "inspector"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserConsole",
|
||
title: constant("Show JavaScript Console"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["browser", "console", "javascript"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserZoomIn",
|
||
title: constant("Zoom In"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["browser", "zoom", "in"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserZoomOut",
|
||
title: constant("Zoom Out"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["browser", "zoom", "out"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserZoomReset",
|
||
title: constant("Actual Size"),
|
||
subtitle: browserPanelSubtitle,
|
||
keywords: ["browser", "zoom", "reset", "actual size"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserClearHistory",
|
||
title: constant("Clear Browser History"),
|
||
subtitle: constant("Browser"),
|
||
keywords: ["browser", "history", "clear"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserSplitRight",
|
||
title: constant("Split Browser Right"),
|
||
subtitle: constant("Browser Layout"),
|
||
keywords: ["browser", "split", "right"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserSplitDown",
|
||
title: constant("Split Browser Down"),
|
||
subtitle: constant("Browser Layout"),
|
||
keywords: ["browser", "split", "down"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.browserDuplicateRight",
|
||
title: constant("Duplicate Browser to the Right"),
|
||
subtitle: constant("Browser Layout"),
|
||
keywords: ["browser", "duplicate", "clone", "split"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||
)
|
||
)
|
||
|
||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: target.commandPaletteCommandId,
|
||
title: constant(target.commandPaletteTitle),
|
||
subtitle: terminalPanelSubtitle,
|
||
keywords: target.commandPaletteKeywords,
|
||
when: { context in
|
||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target))
|
||
}
|
||
)
|
||
)
|
||
}
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.vscodeServeWebStop",
|
||
title: constant("Stop VS Code Inline Server"),
|
||
subtitle: terminalPanelSubtitle,
|
||
keywords: ["vscode", "inline", "serve-web", "stop", "server"],
|
||
when: { context in
|
||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.vscodeServeWebRestart",
|
||
title: constant("Restart VS Code Inline Server"),
|
||
subtitle: terminalPanelSubtitle,
|
||
keywords: ["vscode", "inline", "serve-web", "restart", "server"],
|
||
when: { context in
|
||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalFind",
|
||
title: constant("Find…"),
|
||
subtitle: terminalPanelSubtitle,
|
||
shortcutHint: "⌘F",
|
||
keywords: ["terminal", "find", "search"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalFindNext",
|
||
title: constant("Find Next"),
|
||
subtitle: terminalPanelSubtitle,
|
||
shortcutHint: "⌘G",
|
||
keywords: ["terminal", "find", "next", "search"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalFindPrevious",
|
||
title: constant("Find Previous"),
|
||
subtitle: terminalPanelSubtitle,
|
||
shortcutHint: "⌘⇧G",
|
||
keywords: ["terminal", "find", "previous", "search"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalHideFind",
|
||
title: constant("Hide Find Bar"),
|
||
subtitle: terminalPanelSubtitle,
|
||
shortcutHint: "⌘⇧F",
|
||
keywords: ["terminal", "hide", "find", "search"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalUseSelectionForFind",
|
||
title: constant("Use Selection for Find"),
|
||
subtitle: terminalPanelSubtitle,
|
||
keywords: ["terminal", "selection", "find"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalSplitRight",
|
||
title: constant("Split Right"),
|
||
subtitle: constant("Terminal Layout"),
|
||
keywords: ["terminal", "split", "right"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalSplitDown",
|
||
title: constant("Split Down"),
|
||
subtitle: constant("Terminal Layout"),
|
||
keywords: ["terminal", "split", "down"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalSplitBrowserRight",
|
||
title: constant("Split Browser Right"),
|
||
subtitle: constant("Terminal Layout"),
|
||
keywords: ["terminal", "split", "browser", "right"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.terminalSplitBrowserDown",
|
||
title: constant("Split Browser Down"),
|
||
subtitle: constant("Terminal Layout"),
|
||
keywords: ["terminal", "split", "browser", "down"],
|
||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.toggleSplitZoom",
|
||
title: constant("Toggle Pane Zoom"),
|
||
subtitle: constant("Terminal Layout"),
|
||
keywords: ["terminal", "pane", "split", "zoom", "maximize"],
|
||
when: { context in
|
||
context.bool(CommandPaletteContextKeys.panelIsTerminal) &&
|
||
context.bool(CommandPaletteContextKeys.workspaceHasSplits)
|
||
}
|
||
)
|
||
)
|
||
contributions.append(
|
||
CommandPaletteCommandContribution(
|
||
commandId: "palette.equalizeSplits",
|
||
title: constant("Equalize Splits"),
|
||
subtitle: workspaceSubtitle,
|
||
keywords: ["split", "equalize", "balance", "divider", "layout"],
|
||
when: { $0.bool(CommandPaletteContextKeys.workspaceHasSplits) }
|
||
)
|
||
)
|
||
|
||
return contributions
|
||
}
|
||
|
||
private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) {
|
||
registry.register(commandId: "palette.newWorkspace") {
|
||
tabManager.addWorkspace()
|
||
}
|
||
registry.register(commandId: "palette.openFolder") {
|
||
// Defer so the command palette dismisses before the modal sheet appears.
|
||
DispatchQueue.main.async {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseFiles = false
|
||
panel.canChooseDirectories = true
|
||
panel.allowsMultipleSelection = false
|
||
panel.title = "Open Folder"
|
||
panel.prompt = "Open"
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
tabManager.addWorkspace(workingDirectory: url.path)
|
||
}
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.newWindow") {
|
||
AppDelegate.shared?.openNewMainWindow(nil)
|
||
}
|
||
registry.register(commandId: "palette.installCLI") {
|
||
AppDelegate.shared?.installCmuxCLIInPath(nil)
|
||
}
|
||
registry.register(commandId: "palette.uninstallCLI") {
|
||
AppDelegate.shared?.uninstallCmuxCLIInPath(nil)
|
||
}
|
||
registry.register(commandId: "palette.newTerminalTab") {
|
||
tabManager.newSurface()
|
||
}
|
||
registry.register(commandId: "palette.newBrowserTab") {
|
||
// Let command-palette dismissal complete first so omnibar focus
|
||
// is not blocked by the palette visibility guard.
|
||
DispatchQueue.main.async {
|
||
_ = AppDelegate.shared?.openBrowserAndFocusAddressBar()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.closeTab") {
|
||
tabManager.closeCurrentPanelWithConfirmation()
|
||
}
|
||
registry.register(commandId: "palette.closeWorkspace") {
|
||
tabManager.closeCurrentWorkspaceWithConfirmation()
|
||
}
|
||
registry.register(commandId: "palette.closeWindow") {
|
||
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
window.performClose(nil)
|
||
}
|
||
registry.register(commandId: "palette.toggleFullScreen") {
|
||
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
window.toggleFullScreen(nil)
|
||
}
|
||
registry.register(commandId: "palette.reopenClosedBrowserTab") {
|
||
_ = tabManager.reopenMostRecentlyClosedBrowserPanel()
|
||
}
|
||
registry.register(commandId: "palette.toggleSidebar") {
|
||
sidebarState.toggle()
|
||
}
|
||
registry.register(commandId: "palette.triggerFlash") {
|
||
tabManager.triggerFocusFlash()
|
||
}
|
||
registry.register(commandId: "palette.showNotifications") {
|
||
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
|
||
}
|
||
registry.register(commandId: "palette.jumpUnread") {
|
||
AppDelegate.shared?.jumpToLatestUnread()
|
||
}
|
||
registry.register(commandId: "palette.openSettings") {
|
||
#if DEBUG
|
||
dlog("palette.openSettings.invoke")
|
||
#endif
|
||
if let appDelegate = AppDelegate.shared {
|
||
appDelegate.openPreferencesWindow(debugSource: "palette.openSettings")
|
||
} else {
|
||
#if DEBUG
|
||
dlog("palette.openSettings.missingAppDelegate fallback=1")
|
||
#endif
|
||
AppDelegate.presentPreferencesWindow()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.checkForUpdates") {
|
||
AppDelegate.shared?.checkForUpdates(nil)
|
||
}
|
||
registry.register(commandId: "palette.applyUpdateIfAvailable") {
|
||
AppDelegate.shared?.applyUpdateIfAvailable(nil)
|
||
}
|
||
registry.register(commandId: "palette.attemptUpdate") {
|
||
AppDelegate.shared?.attemptUpdate(nil)
|
||
}
|
||
registry.register(commandId: "palette.restartSocketListener") {
|
||
AppDelegate.shared?.restartSocketListener(nil)
|
||
}
|
||
|
||
registry.register(commandId: "palette.renameWorkspace") {
|
||
beginRenameWorkspaceFlow()
|
||
}
|
||
registry.register(commandId: "palette.clearWorkspaceName") {
|
||
guard let workspace = tabManager.selectedWorkspace else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
tabManager.clearCustomTitle(tabId: workspace.id)
|
||
}
|
||
registry.register(commandId: "palette.toggleWorkspacePin") {
|
||
guard let workspace = tabManager.selectedWorkspace else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
tabManager.setPinned(workspace, pinned: !workspace.isPinned)
|
||
}
|
||
registry.register(commandId: "palette.nextWorkspace") {
|
||
tabManager.selectNextTab()
|
||
}
|
||
registry.register(commandId: "palette.previousWorkspace") {
|
||
tabManager.selectPreviousTab()
|
||
}
|
||
|
||
registry.register(commandId: "palette.renameTab") {
|
||
beginRenameTabFlow()
|
||
}
|
||
registry.register(commandId: "palette.clearTabName") {
|
||
guard let panelContext = focusedPanelContext else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil)
|
||
}
|
||
registry.register(commandId: "palette.toggleTabPin") {
|
||
guard let panelContext = focusedPanelContext else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
panelContext.workspace.setPanelPinned(
|
||
panelId: panelContext.panelId,
|
||
pinned: !panelContext.workspace.isPanelPinned(panelContext.panelId)
|
||
)
|
||
}
|
||
registry.register(commandId: "palette.toggleTabUnread") {
|
||
guard let panelContext = focusedPanelContext else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId)
|
||
|| notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId)
|
||
if hasUnread {
|
||
panelContext.workspace.markPanelRead(panelContext.panelId)
|
||
} else {
|
||
panelContext.workspace.markPanelUnread(panelContext.panelId)
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.nextTabInPane") {
|
||
tabManager.selectNextSurface()
|
||
}
|
||
registry.register(commandId: "palette.previousTabInPane") {
|
||
tabManager.selectPreviousSurface()
|
||
}
|
||
registry.register(commandId: "palette.openWorkspacePullRequests") {
|
||
DispatchQueue.main.async {
|
||
if !openWorkspacePullRequestsInConfiguredBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
}
|
||
|
||
registry.register(commandId: "palette.browserBack") {
|
||
tabManager.focusedBrowserPanel?.goBack()
|
||
}
|
||
registry.register(commandId: "palette.browserForward") {
|
||
tabManager.focusedBrowserPanel?.goForward()
|
||
}
|
||
registry.register(commandId: "palette.browserReload") {
|
||
tabManager.focusedBrowserPanel?.reload()
|
||
}
|
||
registry.register(commandId: "palette.browserOpenDefault") {
|
||
if !openFocusedBrowserInDefaultBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserFocusAddressBar") {
|
||
if !focusFocusedBrowserAddressBar() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserToggleDevTools") {
|
||
if !tabManager.toggleDeveloperToolsFocusedBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserConsole") {
|
||
if !tabManager.showJavaScriptConsoleFocusedBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserZoomIn") {
|
||
if !tabManager.zoomInFocusedBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserZoomOut") {
|
||
if !tabManager.zoomOutFocusedBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserZoomReset") {
|
||
if !tabManager.resetZoomFocusedBrowser() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.browserClearHistory") {
|
||
BrowserHistoryStore.shared.clearHistory()
|
||
}
|
||
registry.register(commandId: "palette.browserSplitRight") {
|
||
_ = tabManager.createBrowserSplit(direction: .right)
|
||
}
|
||
registry.register(commandId: "palette.browserSplitDown") {
|
||
_ = tabManager.createBrowserSplit(direction: .down)
|
||
}
|
||
registry.register(commandId: "palette.browserDuplicateRight") {
|
||
let url = tabManager.focusedBrowserPanel?.preferredURLStringForOmnibar().flatMap(URL.init(string:))
|
||
_ = tabManager.createBrowserSplit(direction: .right, url: url)
|
||
}
|
||
|
||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||
registry.register(commandId: target.commandPaletteCommandId) {
|
||
if !openFocusedDirectory(in: target) {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.vscodeServeWebStop") {
|
||
stopInlineVSCodeServeWeb()
|
||
}
|
||
registry.register(commandId: "palette.vscodeServeWebRestart") {
|
||
if !restartInlineVSCodeServeWeb() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.terminalFind") {
|
||
tabManager.startSearch()
|
||
}
|
||
registry.register(commandId: "palette.terminalFindNext") {
|
||
tabManager.findNext()
|
||
}
|
||
registry.register(commandId: "palette.terminalFindPrevious") {
|
||
tabManager.findPrevious()
|
||
}
|
||
registry.register(commandId: "palette.terminalHideFind") {
|
||
tabManager.hideFind()
|
||
}
|
||
registry.register(commandId: "palette.terminalUseSelectionForFind") {
|
||
tabManager.searchSelection()
|
||
}
|
||
registry.register(commandId: "palette.terminalSplitRight") {
|
||
tabManager.createSplit(direction: .right)
|
||
}
|
||
registry.register(commandId: "palette.terminalSplitDown") {
|
||
tabManager.createSplit(direction: .down)
|
||
}
|
||
registry.register(commandId: "palette.terminalSplitBrowserRight") {
|
||
_ = tabManager.createBrowserSplit(direction: .right)
|
||
}
|
||
registry.register(commandId: "palette.terminalSplitBrowserDown") {
|
||
_ = tabManager.createBrowserSplit(direction: .down)
|
||
}
|
||
registry.register(commandId: "palette.toggleSplitZoom") {
|
||
if !tabManager.toggleFocusedSplitZoom() {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
registry.register(commandId: "palette.equalizeSplits") {
|
||
guard let workspace = tabManager.selectedWorkspace,
|
||
tabManager.equalizeSplits(tabId: workspace.id) else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? {
|
||
guard let workspace = tabManager.selectedWorkspace,
|
||
let panelId = workspace.focusedPanelId,
|
||
let panel = workspace.panels[panelId] else {
|
||
return nil
|
||
}
|
||
return (workspace, panelId, panel)
|
||
}
|
||
|
||
private func workspaceDisplayName(_ workspace: Workspace) -> String {
|
||
let custom = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
if !custom.isEmpty {
|
||
return custom
|
||
}
|
||
let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return title.isEmpty ? "Workspace" : title
|
||
}
|
||
|
||
private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String {
|
||
let title = workspace.panelTitle(panelId: panelId)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
if !title.isEmpty {
|
||
return title
|
||
}
|
||
let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmedFallback.isEmpty ? "Tab" : trimmedFallback
|
||
}
|
||
|
||
private func commandPaletteSelectedIndex(resultCount: Int) -> Int {
|
||
guard resultCount > 0 else { return 0 }
|
||
return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1)
|
||
}
|
||
|
||
static func commandPaletteScrollPositionAnchor(
|
||
selectedIndex: Int,
|
||
resultCount: Int
|
||
) -> UnitPoint? {
|
||
guard resultCount > 0 else { return nil }
|
||
if selectedIndex <= 0 {
|
||
return UnitPoint.top
|
||
}
|
||
if selectedIndex >= resultCount - 1 {
|
||
return UnitPoint.bottom
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) {
|
||
guard resultCount > 0 else {
|
||
commandPaletteScrollTargetIndex = nil
|
||
commandPaletteScrollTargetAnchor = nil
|
||
return
|
||
}
|
||
|
||
let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount)
|
||
commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor(
|
||
selectedIndex: selectedIndex,
|
||
resultCount: resultCount
|
||
)
|
||
|
||
let assignTarget = {
|
||
commandPaletteScrollTargetIndex = selectedIndex
|
||
}
|
||
if animated {
|
||
withAnimation(.easeOut(duration: 0.1)) {
|
||
assignTarget()
|
||
}
|
||
} else {
|
||
assignTarget()
|
||
}
|
||
}
|
||
|
||
private func moveCommandPaletteSelection(by delta: Int) {
|
||
let count = commandPaletteResults.count
|
||
guard count > 0 else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
let current = commandPaletteSelectedIndex(resultCount: count)
|
||
commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1)
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
|
||
private func handleCommandPaletteControlNavigationKey(
|
||
modifiers: EventModifiers,
|
||
delta: Int
|
||
) -> BackportKeyPressResult {
|
||
guard modifiers.contains(.control),
|
||
!modifiers.contains(.command),
|
||
!modifiers.contains(.shift),
|
||
!modifiers.contains(.option) else {
|
||
return .ignored
|
||
}
|
||
moveCommandPaletteSelection(by: delta)
|
||
return .handled
|
||
}
|
||
|
||
static func commandPaletteShouldPopRenameInputOnDelete(
|
||
renameDraft: String,
|
||
modifiers: EventModifiers
|
||
) -> Bool {
|
||
let blockedModifiers: EventModifiers = [.command, .control, .option, .shift]
|
||
guard modifiers.intersection(blockedModifiers).isEmpty else { return false }
|
||
return renameDraft.isEmpty
|
||
}
|
||
|
||
private func handleCommandPaletteRenameDeleteBackward(
|
||
modifiers: EventModifiers
|
||
) -> BackportKeyPressResult {
|
||
guard case .renameInput = commandPaletteMode else { return .ignored }
|
||
let blockedModifiers: EventModifiers = [.command, .control, .option, .shift]
|
||
guard modifiers.intersection(blockedModifiers).isEmpty else { return .ignored }
|
||
|
||
if Self.commandPaletteShouldPopRenameInputOnDelete(
|
||
renameDraft: commandPaletteRenameDraft,
|
||
modifiers: modifiers
|
||
) {
|
||
commandPaletteMode = .commands
|
||
resetCommandPaletteSearchFocus()
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
return .handled
|
||
}
|
||
|
||
if let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||
let editor = window.firstResponder as? NSTextView,
|
||
editor.isFieldEditor {
|
||
editor.deleteBackward(nil)
|
||
commandPaletteRenameDraft = editor.string
|
||
} else if !commandPaletteRenameDraft.isEmpty {
|
||
commandPaletteRenameDraft.removeLast()
|
||
}
|
||
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
return .handled
|
||
}
|
||
|
||
private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) {
|
||
let visibleResults = visibleResults ?? Array(commandPaletteResults)
|
||
guard !visibleResults.isEmpty else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
let index = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||
runCommandPaletteCommand(visibleResults[index].command)
|
||
}
|
||
|
||
private func runCommandPaletteCommand(_ command: CommandPaletteCommand) {
|
||
#if DEBUG
|
||
dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)")
|
||
#endif
|
||
recordCommandPaletteUsage(command.id)
|
||
command.action()
|
||
if command.dismissOnRun {
|
||
dismissCommandPalette(restoreFocus: false)
|
||
}
|
||
}
|
||
|
||
private func toggleCommandPalette() {
|
||
if isCommandPalettePresented {
|
||
dismissCommandPalette()
|
||
} else {
|
||
presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix)
|
||
}
|
||
}
|
||
|
||
private func openCommandPaletteCommands() {
|
||
toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix)
|
||
}
|
||
|
||
private func openCommandPaletteSwitcher() {
|
||
toggleCommandPalette(initialQuery: "")
|
||
}
|
||
|
||
private func toggleCommandPalette(initialQuery: String) {
|
||
if isCommandPalettePresented {
|
||
dismissCommandPalette()
|
||
} else {
|
||
presentCommandPalette(initialQuery: initialQuery)
|
||
}
|
||
}
|
||
|
||
private func openCommandPaletteRenameTabInput() {
|
||
if !isCommandPalettePresented {
|
||
presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix)
|
||
}
|
||
beginRenameTabFlow()
|
||
}
|
||
|
||
private func openCommandPaletteRenameWorkspaceInput() {
|
||
if !isCommandPalettePresented {
|
||
presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix)
|
||
}
|
||
beginRenameWorkspaceFlow()
|
||
}
|
||
|
||
static func shouldHandleCommandPaletteRequest(
|
||
observedWindow: NSWindow?,
|
||
requestedWindow: NSWindow?,
|
||
keyWindow: NSWindow?,
|
||
mainWindow: NSWindow?
|
||
) -> Bool {
|
||
guard let observedWindow else { return false }
|
||
if let requestedWindow {
|
||
return requestedWindow === observedWindow
|
||
}
|
||
if let keyWindow {
|
||
return keyWindow === observedWindow
|
||
}
|
||
if let mainWindow {
|
||
return mainWindow === observedWindow
|
||
}
|
||
return false
|
||
}
|
||
|
||
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||
focusedPanelIsBrowser: Bool,
|
||
focusedBrowserAddressBarPanelId: UUID?,
|
||
focusedPanelId: UUID
|
||
) -> Bool {
|
||
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
|
||
}
|
||
|
||
private func syncCommandPaletteDebugStateForObservedWindow() {
|
||
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||
AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window)
|
||
let visibleResultCount = commandPaletteResults.count
|
||
let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0
|
||
AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window)
|
||
AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window)
|
||
}
|
||
|
||
private func commandPaletteDebugSnapshot() -> CommandPaletteDebugSnapshot {
|
||
guard isCommandPalettePresented else { return .empty }
|
||
|
||
let mode: String
|
||
switch commandPaletteMode {
|
||
case .commands:
|
||
mode = commandPaletteListScope.rawValue
|
||
case .renameInput:
|
||
mode = "rename_input"
|
||
case .renameConfirm:
|
||
mode = "rename_confirm"
|
||
}
|
||
|
||
let rows = Array(commandPaletteResults.prefix(20)).map { result in
|
||
CommandPaletteDebugResultRow(
|
||
commandId: result.command.id,
|
||
title: result.command.title,
|
||
shortcutHint: result.command.shortcutHint,
|
||
trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text,
|
||
score: result.score
|
||
)
|
||
}
|
||
|
||
return CommandPaletteDebugSnapshot(
|
||
query: commandPaletteQueryForMatching,
|
||
mode: mode,
|
||
results: rows
|
||
)
|
||
}
|
||
|
||
private func presentCommandPalette(initialQuery: String) {
|
||
if let panelContext = focusedPanelContext {
|
||
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
|
||
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
|
||
focusedPanelId: panelContext.panelId
|
||
)
|
||
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
|
||
workspaceId: panelContext.workspace.id,
|
||
panelId: panelContext.panelId,
|
||
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
|
||
)
|
||
} else {
|
||
commandPaletteRestoreFocusTarget = nil
|
||
}
|
||
isCommandPalettePresented = true
|
||
refreshCommandPaletteUsageHistory()
|
||
resetCommandPaletteListState(initialQuery: initialQuery)
|
||
}
|
||
|
||
private func resetCommandPaletteListState(initialQuery: String) {
|
||
commandPaletteMode = .commands
|
||
commandPaletteQuery = initialQuery
|
||
commandPaletteRenameDraft = ""
|
||
commandPaletteSelectedResultIndex = 0
|
||
commandPaletteHoveredResultIndex = nil
|
||
commandPaletteScrollTargetIndex = nil
|
||
commandPaletteScrollTargetAnchor = nil
|
||
resetCommandPaletteSearchFocus()
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
|
||
private func dismissCommandPalette(restoreFocus: Bool = true) {
|
||
let focusTarget = commandPaletteRestoreFocusTarget
|
||
isCommandPalettePresented = false
|
||
commandPaletteMode = .commands
|
||
commandPaletteQuery = ""
|
||
commandPaletteRenameDraft = ""
|
||
commandPaletteSelectedResultIndex = 0
|
||
commandPaletteHoveredResultIndex = nil
|
||
commandPaletteScrollTargetIndex = nil
|
||
commandPaletteScrollTargetAnchor = nil
|
||
isCommandPaletteSearchFocused = false
|
||
isCommandPaletteRenameFocused = false
|
||
commandPaletteRestoreFocusTarget = nil
|
||
if let window = observedWindow {
|
||
_ = window.makeFirstResponder(nil)
|
||
}
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
|
||
guard restoreFocus, let focusTarget else { return }
|
||
restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6)
|
||
}
|
||
|
||
private func restoreCommandPaletteFocus(
|
||
target: CommandPaletteRestoreFocusTarget,
|
||
attemptsRemaining: Int
|
||
) {
|
||
guard !isCommandPalettePresented else { return }
|
||
guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return }
|
||
|
||
if let window = observedWindow, !window.isKeyWindow {
|
||
window.makeKeyAndOrderFront(nil)
|
||
}
|
||
tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true)
|
||
|
||
if let context = focusedPanelContext,
|
||
context.workspace.id == target.workspaceId,
|
||
context.panelId == target.panelId {
|
||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||
return
|
||
}
|
||
|
||
guard attemptsRemaining > 0 else { return }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||
guard !isCommandPalettePresented else { return }
|
||
if let context = focusedPanelContext,
|
||
context.workspace.id == target.workspaceId,
|
||
context.panelId == target.panelId {
|
||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||
return
|
||
}
|
||
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
|
||
}
|
||
}
|
||
|
||
private func restoreCommandPaletteInputFocusIfNeeded(
|
||
target: CommandPaletteRestoreFocusTarget,
|
||
attemptsRemaining: Int
|
||
) {
|
||
guard !isCommandPalettePresented else { return }
|
||
guard target.intent == .browserAddressBar else { return }
|
||
guard attemptsRemaining > 0 else { return }
|
||
guard let appDelegate = AppDelegate.shared else { return }
|
||
|
||
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
|
||
return
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||
restoreCommandPaletteInputFocusIfNeeded(
|
||
target: target,
|
||
attemptsRemaining: attemptsRemaining - 1
|
||
)
|
||
}
|
||
}
|
||
|
||
private func resetCommandPaletteSearchFocus() {
|
||
applyCommandPaletteInputFocusPolicy(.search)
|
||
}
|
||
|
||
private func resetCommandPaletteRenameFocus() {
|
||
applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy())
|
||
}
|
||
|
||
private func handleCommandPaletteRenameInputInteraction() {
|
||
guard isCommandPalettePresented else { return }
|
||
guard case .renameInput = commandPaletteMode else { return }
|
||
applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy())
|
||
}
|
||
|
||
private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy {
|
||
let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
|
||
let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus
|
||
? .selectAll
|
||
: .caretAtEnd
|
||
return CommandPaletteInputFocusPolicy(
|
||
focusTarget: .rename,
|
||
selectionBehavior: selectionBehavior
|
||
)
|
||
}
|
||
|
||
private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) {
|
||
DispatchQueue.main.async {
|
||
switch policy.focusTarget {
|
||
case .search:
|
||
isCommandPaletteRenameFocused = false
|
||
isCommandPaletteSearchFocused = true
|
||
case .rename:
|
||
isCommandPaletteSearchFocused = false
|
||
isCommandPaletteRenameFocused = true
|
||
}
|
||
applyCommandPaletteTextSelection(policy.selectionBehavior)
|
||
}
|
||
}
|
||
|
||
private func applyCommandPaletteTextSelection(
|
||
_ behavior: CommandPaletteTextSelectionBehavior,
|
||
attemptsRemaining: Int = 20
|
||
) {
|
||
guard isCommandPalettePresented else { return }
|
||
switch behavior {
|
||
case .selectAll:
|
||
guard case .renameInput = commandPaletteMode else { return }
|
||
case .caretAtEnd:
|
||
switch commandPaletteMode {
|
||
case .commands, .renameInput:
|
||
break
|
||
case .renameConfirm:
|
||
return
|
||
}
|
||
}
|
||
guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||
|
||
if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor {
|
||
let length = (editor.string as NSString).length
|
||
switch behavior {
|
||
case .selectAll:
|
||
editor.setSelectedRange(NSRange(location: 0, length: length))
|
||
case .caretAtEnd:
|
||
editor.setSelectedRange(NSRange(location: length, length: 0))
|
||
}
|
||
return
|
||
}
|
||
|
||
guard attemptsRemaining > 0 else { return }
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
||
applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1)
|
||
}
|
||
}
|
||
|
||
private func refreshCommandPaletteUsageHistory() {
|
||
commandPaletteUsageHistoryByCommandId = loadCommandPaletteUsageHistory()
|
||
}
|
||
|
||
private func loadCommandPaletteUsageHistory() -> [String: CommandPaletteUsageEntry] {
|
||
guard let data = UserDefaults.standard.data(forKey: Self.commandPaletteUsageDefaultsKey) else {
|
||
return [:]
|
||
}
|
||
return (try? JSONDecoder().decode([String: CommandPaletteUsageEntry].self, from: data)) ?? [:]
|
||
}
|
||
|
||
private func persistCommandPaletteUsageHistory(_ history: [String: CommandPaletteUsageEntry]) {
|
||
guard let data = try? JSONEncoder().encode(history) else { return }
|
||
UserDefaults.standard.set(data, forKey: Self.commandPaletteUsageDefaultsKey)
|
||
}
|
||
|
||
private func recordCommandPaletteUsage(_ commandId: String) {
|
||
var history = commandPaletteUsageHistoryByCommandId
|
||
var entry = history[commandId] ?? CommandPaletteUsageEntry(useCount: 0, lastUsedAt: 0)
|
||
entry.useCount += 1
|
||
entry.lastUsedAt = Date().timeIntervalSince1970
|
||
history[commandId] = entry
|
||
commandPaletteUsageHistoryByCommandId = history
|
||
persistCommandPaletteUsageHistory(history)
|
||
}
|
||
|
||
private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int {
|
||
guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 }
|
||
|
||
let now = Date().timeIntervalSince1970
|
||
let ageDays = max(0, now - entry.lastUsedAt) / 86_400
|
||
let recencyBoost = max(0, 320 - Int(ageDays * 20))
|
||
let countBoost = min(180, entry.useCount * 12)
|
||
let totalBoost = recencyBoost + countBoost
|
||
|
||
return queryIsEmpty ? totalBoost : max(0, totalBoost / 3)
|
||
}
|
||
|
||
private func beginRenameWorkspaceFlow() {
|
||
guard let workspace = tabManager.selectedWorkspace else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
let target = CommandPaletteRenameTarget(
|
||
kind: .workspace(workspaceId: workspace.id),
|
||
currentName: workspaceDisplayName(workspace)
|
||
)
|
||
startRenameFlow(target)
|
||
}
|
||
|
||
private func beginRenameTabFlow() {
|
||
guard let panelContext = focusedPanelContext else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
let panelName = panelDisplayName(
|
||
workspace: panelContext.workspace,
|
||
panelId: panelContext.panelId,
|
||
fallback: panelContext.panel.displayTitle
|
||
)
|
||
let target = CommandPaletteRenameTarget(
|
||
kind: .tab(workspaceId: panelContext.workspace.id, panelId: panelContext.panelId),
|
||
currentName: panelName
|
||
)
|
||
startRenameFlow(target)
|
||
}
|
||
|
||
private func startRenameFlow(_ target: CommandPaletteRenameTarget) {
|
||
commandPaletteRenameDraft = target.currentName
|
||
commandPaletteMode = .renameInput(target)
|
||
resetCommandPaletteRenameFocus()
|
||
syncCommandPaletteDebugStateForObservedWindow()
|
||
}
|
||
|
||
private func continueRenameFlow(target: CommandPaletteRenameTarget) {
|
||
guard case .renameInput(let activeTarget) = commandPaletteMode,
|
||
activeTarget == target else { return }
|
||
applyRenameFlow(target: target, proposedName: commandPaletteRenameDraft)
|
||
}
|
||
|
||
private func applyRenameFlow(target: CommandPaletteRenameTarget, proposedName: String) {
|
||
let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let normalizedName: String? = trimmedName.isEmpty ? nil : trimmedName
|
||
|
||
switch target.kind {
|
||
case .workspace(let workspaceId):
|
||
tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName)
|
||
case .tab(let workspaceId, let panelId):
|
||
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName)
|
||
}
|
||
|
||
dismissCommandPalette()
|
||
}
|
||
|
||
private func focusFocusedBrowserAddressBar() -> Bool {
|
||
guard let panel = tabManager.focusedBrowserPanel else { return false }
|
||
_ = panel.requestAddressBarFocus()
|
||
NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id)
|
||
return true
|
||
}
|
||
|
||
private func openFocusedBrowserInDefaultBrowser() -> Bool {
|
||
guard let panel = tabManager.focusedBrowserPanel,
|
||
let rawURL = panel.preferredURLStringForOmnibar(),
|
||
let url = URL(string: rawURL),
|
||
let scheme = url.scheme?.lowercased(),
|
||
scheme == "http" || scheme == "https" else {
|
||
return false
|
||
}
|
||
return NSWorkspace.shared.open(url)
|
||
}
|
||
|
||
private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool {
|
||
guard let workspace = tabManager.selectedWorkspace else { return false }
|
||
let pullRequests = workspace.sidebarPullRequestsInDisplayOrder()
|
||
guard !pullRequests.isEmpty else { return false }
|
||
|
||
var openedCount = 0
|
||
if openSidebarPullRequestLinksInCmuxBrowser {
|
||
for pullRequest in pullRequests {
|
||
if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil {
|
||
openedCount += 1
|
||
} else if NSWorkspace.shared.open(pullRequest.url) {
|
||
openedCount += 1
|
||
}
|
||
}
|
||
return openedCount > 0
|
||
}
|
||
|
||
for pullRequest in pullRequests {
|
||
if NSWorkspace.shared.open(pullRequest.url) {
|
||
openedCount += 1
|
||
}
|
||
}
|
||
return openedCount > 0
|
||
}
|
||
|
||
private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool {
|
||
guard let directoryURL = focusedTerminalDirectoryURL() else { return false }
|
||
return openFocusedDirectory(directoryURL, in: target)
|
||
}
|
||
|
||
private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool {
|
||
switch target {
|
||
case .finder:
|
||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
|
||
return true
|
||
case .vscode:
|
||
return openFocusedDirectoryInInlineVSCode(directoryURL)
|
||
default:
|
||
guard let applicationURL = target.applicationURL() else { return false }
|
||
let configuration = NSWorkspace.OpenConfiguration()
|
||
NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration)
|
||
return true
|
||
}
|
||
}
|
||
|
||
private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool {
|
||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(),
|
||
let workspace = tabManager.selectedWorkspace,
|
||
let sourcePanelId = workspace.focusedPanelId else {
|
||
return false
|
||
}
|
||
let sourceTabId = workspace.id
|
||
let tabManager = tabManager
|
||
VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
|
||
guard let serveWebURL,
|
||
let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL(
|
||
baseWebUIURL: serveWebURL,
|
||
directoryPath: directoryURL.path
|
||
) else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
guard tabManager.newBrowserSplit(
|
||
tabId: sourceTabId,
|
||
fromPanelId: sourcePanelId,
|
||
orientation: SplitDirection.right.orientation,
|
||
insertFirst: SplitDirection.right.insertFirst,
|
||
url: openFolderURL,
|
||
focus: true
|
||
) != nil else {
|
||
NSSound.beep()
|
||
return
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func stopInlineVSCodeServeWeb() {
|
||
VSCodeServeWebController.shared.stop()
|
||
}
|
||
|
||
private func restartInlineVSCodeServeWeb() -> Bool {
|
||
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else {
|
||
return false
|
||
}
|
||
VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
|
||
if serveWebURL == nil {
|
||
NSSound.beep()
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func focusedTerminalDirectoryURL() -> URL? {
|
||
guard let workspace = tabManager.selectedWorkspace else { return nil }
|
||
let rawDirectory: String = {
|
||
if let focusedPanelId = workspace.focusedPanelId,
|
||
let directory = workspace.panelDirectories[focusedPanelId] {
|
||
return directory
|
||
}
|
||
return workspace.currentDirectory
|
||
}()
|
||
let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
guard FileManager.default.fileExists(atPath: trimmed) else { return nil }
|
||
return URL(fileURLWithPath: trimmed, isDirectory: true)
|
||
}
|
||
|
||
#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
|
||
}
|
||
|
||
struct CommandPaletteSwitcherSearchMetadata {
|
||
let directories: [String]
|
||
let branches: [String]
|
||
let ports: [Int]
|
||
|
||
init(
|
||
directories: [String] = [],
|
||
branches: [String] = [],
|
||
ports: [Int] = []
|
||
) {
|
||
self.directories = directories
|
||
self.branches = branches
|
||
self.ports = ports
|
||
}
|
||
}
|
||
|
||
enum CommandPaletteSwitcherSearchIndexer {
|
||
enum MetadataDetail {
|
||
case workspace
|
||
case surface
|
||
}
|
||
|
||
private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ")
|
||
|
||
static func keywords(
|
||
baseKeywords: [String],
|
||
metadata: CommandPaletteSwitcherSearchMetadata,
|
||
detail: MetadataDetail = .surface
|
||
) -> [String] {
|
||
let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail)
|
||
return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords)
|
||
}
|
||
|
||
private static func metadataKeywordsForSearch(
|
||
_ metadata: CommandPaletteSwitcherSearchMetadata,
|
||
detail: MetadataDetail
|
||
) -> [String] {
|
||
let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) }
|
||
let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) }
|
||
let portTokens = metadata.ports.flatMap(portTokensForSearch)
|
||
|
||
var contextKeywords: [String] = []
|
||
if !directoryTokens.isEmpty {
|
||
contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"])
|
||
}
|
||
if !branchTokens.isEmpty {
|
||
contextKeywords.append(contentsOf: ["branch", "git"])
|
||
}
|
||
if !portTokens.isEmpty {
|
||
contextKeywords.append(contentsOf: ["port", "ports"])
|
||
}
|
||
|
||
return contextKeywords + directoryTokens + branchTokens + portTokens
|
||
}
|
||
|
||
private static func directoryTokensForSearch(
|
||
_ rawDirectory: String,
|
||
detail: MetadataDetail
|
||
) -> [String] {
|
||
let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return [] }
|
||
|
||
let standardized = (trimmed as NSString).standardizingPath
|
||
let canonical = standardized.isEmpty ? trimmed : standardized
|
||
let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath
|
||
switch detail {
|
||
case .workspace:
|
||
return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated])
|
||
case .surface:
|
||
let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent
|
||
let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty }
|
||
return uniqueNormalizedPreservingOrder(
|
||
[trimmed, canonical, abbreviated, basename] + components
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func branchTokensForSearch(
|
||
_ rawBranch: String,
|
||
detail: MetadataDetail
|
||
) -> [String] {
|
||
let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return [] }
|
||
switch detail {
|
||
case .workspace:
|
||
return [trimmed]
|
||
case .surface:
|
||
let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty }
|
||
return uniqueNormalizedPreservingOrder([trimmed] + components)
|
||
}
|
||
}
|
||
|
||
private static func portTokensForSearch(_ port: Int) -> [String] {
|
||
guard (1...65535).contains(port) else { return [] }
|
||
let portText = String(port)
|
||
return [portText, ":\(portText)"]
|
||
}
|
||
|
||
private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] {
|
||
var result: [String] = []
|
||
var seen: Set<String> = []
|
||
result.reserveCapacity(values.count)
|
||
|
||
for value in values {
|
||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { continue }
|
||
let normalizedKey = trimmed
|
||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||
.lowercased()
|
||
guard seen.insert(normalizedKey).inserted else { continue }
|
||
result.append(trimmed)
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
enum CommandPaletteFuzzyMatcher {
|
||
private static let tokenBoundaryChars: Set<Character> = [" ", "-", "_", "/", ".", ":"]
|
||
|
||
static func score(query: String, candidate: String) -> Int? {
|
||
score(query: query, candidates: [candidate])
|
||
}
|
||
|
||
static func score(query: String, candidates: [String]) -> Int? {
|
||
let normalizedQuery = normalize(query)
|
||
guard !normalizedQuery.isEmpty else { return 0 }
|
||
let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty }
|
||
guard !tokens.isEmpty else { return 0 }
|
||
|
||
let normalizedCandidates = candidates
|
||
.map(normalize)
|
||
.filter { !$0.isEmpty }
|
||
guard !normalizedCandidates.isEmpty else { return nil }
|
||
|
||
var totalScore = 0
|
||
for token in tokens {
|
||
var bestTokenScore: Int?
|
||
for candidate in normalizedCandidates {
|
||
guard let candidateScore = scoreToken(token, in: candidate) else { continue }
|
||
bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore)
|
||
}
|
||
guard let bestTokenScore else { return nil }
|
||
totalScore += bestTokenScore
|
||
}
|
||
return totalScore
|
||
}
|
||
|
||
static func matchCharacterIndices(query: String, candidate: String) -> Set<Int> {
|
||
let normalizedQuery = normalize(query)
|
||
guard !normalizedQuery.isEmpty else { return [] }
|
||
|
||
let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty }
|
||
guard !tokens.isEmpty else { return [] }
|
||
|
||
let loweredCandidate = normalize(candidate)
|
||
guard !loweredCandidate.isEmpty else { return [] }
|
||
|
||
let candidateChars = Array(loweredCandidate)
|
||
var matched: Set<Int> = []
|
||
|
||
for token in tokens {
|
||
if token == loweredCandidate {
|
||
matched.formUnion(0..<candidateChars.count)
|
||
continue
|
||
}
|
||
|
||
if loweredCandidate.hasPrefix(token) {
|
||
matched.formUnion(0..<min(token.count, candidateChars.count))
|
||
continue
|
||
}
|
||
|
||
if let range = loweredCandidate.range(of: token) {
|
||
let start = loweredCandidate.distance(from: loweredCandidate.startIndex, to: range.lowerBound)
|
||
let end = min(candidateChars.count, start + token.count)
|
||
matched.formUnion(start..<end)
|
||
continue
|
||
}
|
||
|
||
if let initialism = initialismMatchIndices(token: token, candidate: loweredCandidate) {
|
||
matched.formUnion(initialism)
|
||
continue
|
||
}
|
||
|
||
if let stitched = stitchedWordPrefixMatchIndices(token: token, candidate: loweredCandidate) {
|
||
matched.formUnion(stitched)
|
||
continue
|
||
}
|
||
|
||
guard token.count <= 3 else { continue }
|
||
if let subsequence = subsequenceMatchIndices(token: token, candidate: loweredCandidate) {
|
||
matched.formUnion(subsequence)
|
||
}
|
||
}
|
||
|
||
return matched
|
||
}
|
||
|
||
private static func normalize(_ text: String) -> String {
|
||
text
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||
.lowercased()
|
||
}
|
||
|
||
private static func scoreToken(_ token: String, in candidate: String) -> Int? {
|
||
guard !token.isEmpty else { return 0 }
|
||
|
||
let candidateChars = Array(candidate)
|
||
let tokenChars = Array(token)
|
||
guard tokenChars.count <= candidateChars.count else { return nil }
|
||
|
||
if token == candidate {
|
||
return 8000
|
||
}
|
||
if candidate.hasPrefix(token) {
|
||
return 6800 - max(0, candidate.count - token.count)
|
||
}
|
||
|
||
var bestScore: Int?
|
||
if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) {
|
||
bestScore = max(bestScore ?? wordExactScore, wordExactScore)
|
||
}
|
||
if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) {
|
||
bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore)
|
||
}
|
||
|
||
if let range = candidate.range(of: token) {
|
||
let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound)
|
||
let lengthPenalty = max(0, candidate.count - token.count)
|
||
let boundaryBoost: Int = {
|
||
guard distance > 0 else { return 220 }
|
||
let prior = candidateChars[distance - 1]
|
||
return tokenBoundaryChars.contains(prior) ? 180 : 0
|
||
}()
|
||
let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty
|
||
bestScore = max(bestScore ?? containsScore, containsScore)
|
||
}
|
||
|
||
if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) {
|
||
bestScore = max(bestScore ?? initialismScore, initialismScore)
|
||
}
|
||
|
||
if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) {
|
||
bestScore = max(bestScore ?? stitchedScore, stitchedScore)
|
||
}
|
||
|
||
if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) {
|
||
bestScore = max(bestScore ?? subsequence, subsequence)
|
||
}
|
||
|
||
guard let bestScore else { return nil }
|
||
return max(1, bestScore)
|
||
}
|
||
|
||
private static func bestWordScore(
|
||
tokenChars: [Character],
|
||
candidateChars: [Character],
|
||
requireExactWord: Bool
|
||
) -> Int? {
|
||
guard !tokenChars.isEmpty else { return nil }
|
||
|
||
var best: Int?
|
||
for segment in wordSegments(candidateChars) {
|
||
let wordLength = segment.end - segment.start
|
||
guard tokenChars.count <= wordLength else { continue }
|
||
|
||
var matchesPrefix = true
|
||
for offset in 0..<tokenChars.count where candidateChars[segment.start + offset] != tokenChars[offset] {
|
||
matchesPrefix = false
|
||
break
|
||
}
|
||
guard matchesPrefix else { continue }
|
||
if requireExactWord && tokenChars.count != wordLength { continue }
|
||
|
||
let lengthPenalty = max(0, wordLength - tokenChars.count) * 6
|
||
let distancePenalty = segment.start * 8
|
||
let trailingPenalty = max(0, candidateChars.count - wordLength)
|
||
let scoreBase = requireExactWord ? 6200 : 5600
|
||
let score = scoreBase - distancePenalty - lengthPenalty - trailingPenalty
|
||
best = max(best ?? score, score)
|
||
}
|
||
|
||
return best
|
||
}
|
||
|
||
private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? {
|
||
guard !tokenChars.isEmpty else { return nil }
|
||
let segments = wordSegments(candidateChars)
|
||
guard tokenChars.count <= segments.count else { return nil }
|
||
|
||
var matchedStarts: [Int] = []
|
||
var searchWordIndex = 0
|
||
|
||
for tokenChar in tokenChars {
|
||
var found = false
|
||
while searchWordIndex < segments.count {
|
||
let segment = segments[searchWordIndex]
|
||
searchWordIndex += 1
|
||
if candidateChars[segment.start] == tokenChar {
|
||
matchedStarts.append(segment.start)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found { return nil }
|
||
}
|
||
|
||
let firstStart = matchedStarts.first ?? 0
|
||
let skippedWords = max(0, segments.count - tokenChars.count)
|
||
return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30)
|
||
}
|
||
|
||
private static func tokenPrefixMatches(
|
||
tokenChars: [Character],
|
||
tokenStart: Int,
|
||
length: Int,
|
||
candidateChars: [Character],
|
||
candidateStart: Int
|
||
) -> Bool {
|
||
guard length > 0 else { return false }
|
||
guard tokenStart + length <= tokenChars.count else { return false }
|
||
guard candidateStart + length <= candidateChars.count else { return false }
|
||
|
||
for offset in 0..<length where tokenChars[tokenStart + offset] != candidateChars[candidateStart + offset] {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private static func stitchedWordPrefixScore(tokenChars: [Character], candidateChars: [Character]) -> Int? {
|
||
guard tokenChars.count >= 4 else { return nil }
|
||
let segments = wordSegments(candidateChars)
|
||
guard segments.count >= 2 else { return nil }
|
||
|
||
struct StitchState: Hashable {
|
||
let tokenIndex: Int
|
||
let wordIndex: Int
|
||
let usedWords: Int
|
||
}
|
||
|
||
var memo: [StitchState: Int?] = [:]
|
||
|
||
func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? {
|
||
if tokenIndex == tokenChars.count {
|
||
return usedWords >= 2 ? 0 : nil
|
||
}
|
||
guard wordIndex < segments.count else { return nil }
|
||
|
||
let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords)
|
||
if let cached = memo[state] {
|
||
return cached
|
||
}
|
||
|
||
var best: Int?
|
||
let remainingChars = tokenChars.count - tokenIndex
|
||
for segmentIndex in wordIndex..<segments.count {
|
||
let segment = segments[segmentIndex]
|
||
let segmentLength = segment.end - segment.start
|
||
let maxChunk = min(segmentLength, remainingChars)
|
||
guard maxChunk > 0 else { continue }
|
||
|
||
let skippedWords = max(0, segmentIndex - wordIndex)
|
||
let skipPenalty = skippedWords * 120
|
||
for chunkLength in stride(from: maxChunk, through: 1, by: -1) {
|
||
guard tokenPrefixMatches(
|
||
tokenChars: tokenChars,
|
||
tokenStart: tokenIndex,
|
||
length: chunkLength,
|
||
candidateChars: candidateChars,
|
||
candidateStart: segment.start
|
||
) else {
|
||
continue
|
||
}
|
||
guard let suffixScore = dfs(
|
||
tokenIndex: tokenIndex + chunkLength,
|
||
wordIndex: segmentIndex + 1,
|
||
usedWords: min(2, usedWords + 1)
|
||
) else {
|
||
continue
|
||
}
|
||
|
||
let chunkCoverage = chunkLength * 220
|
||
let contiguityBonus = segmentIndex == wordIndex ? 80 : 0
|
||
let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9
|
||
let distancePenalty = segment.start * 4
|
||
let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty
|
||
let totalScore = suffixScore + chunkScore
|
||
best = max(best ?? totalScore, totalScore)
|
||
}
|
||
}
|
||
|
||
memo[state] = best
|
||
return best
|
||
}
|
||
|
||
guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil }
|
||
let lengthPenalty = max(0, candidateChars.count - tokenChars.count)
|
||
return 3500 + stitchedScore - lengthPenalty
|
||
}
|
||
|
||
private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set<Int>? {
|
||
let tokenChars = Array(token)
|
||
let candidateChars = Array(candidate)
|
||
guard tokenChars.count >= 4 else { return nil }
|
||
|
||
let segments = wordSegments(candidateChars)
|
||
guard segments.count >= 2 else { return nil }
|
||
|
||
var tokenIndex = 0
|
||
var nextWordIndex = 0
|
||
var usedWords = 0
|
||
var matchedIndices: Set<Int> = []
|
||
|
||
while tokenIndex < tokenChars.count {
|
||
let remainingChars = tokenChars.count - tokenIndex
|
||
var foundMatch = false
|
||
|
||
for segmentIndex in nextWordIndex..<segments.count {
|
||
let segment = segments[segmentIndex]
|
||
let segmentLength = segment.end - segment.start
|
||
let maxChunk = min(segmentLength, remainingChars)
|
||
guard maxChunk > 0 else { continue }
|
||
|
||
for chunkLength in stride(from: maxChunk, through: 1, by: -1) {
|
||
guard tokenPrefixMatches(
|
||
tokenChars: tokenChars,
|
||
tokenStart: tokenIndex,
|
||
length: chunkLength,
|
||
candidateChars: candidateChars,
|
||
candidateStart: segment.start
|
||
) else {
|
||
continue
|
||
}
|
||
|
||
matchedIndices.formUnion(segment.start..<(segment.start + chunkLength))
|
||
tokenIndex += chunkLength
|
||
nextWordIndex = segmentIndex + 1
|
||
usedWords += 1
|
||
foundMatch = true
|
||
break
|
||
}
|
||
|
||
if foundMatch { break }
|
||
}
|
||
|
||
if !foundMatch { return nil }
|
||
}
|
||
|
||
guard usedWords >= 2 else { return nil }
|
||
return matchedIndices
|
||
}
|
||
|
||
private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] {
|
||
var segments: [(start: Int, end: Int)] = []
|
||
var index = 0
|
||
|
||
while index < candidateChars.count {
|
||
while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) {
|
||
index += 1
|
||
}
|
||
guard index < candidateChars.count else { break }
|
||
let start = index
|
||
while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) {
|
||
index += 1
|
||
}
|
||
segments.append((start: start, end: index))
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
private static func subsequenceScore(token: String, candidate: String) -> Int? {
|
||
let tokenChars = Array(token)
|
||
let candidateChars = Array(candidate)
|
||
guard tokenChars.count <= candidateChars.count else { return nil }
|
||
|
||
var searchIndex = 0
|
||
var previousMatch = -1
|
||
var consecutiveRun = 0
|
||
var score = 0
|
||
|
||
for tokenChar in tokenChars {
|
||
var foundIndex: Int?
|
||
while searchIndex < candidateChars.count {
|
||
if candidateChars[searchIndex] == tokenChar {
|
||
foundIndex = searchIndex
|
||
break
|
||
}
|
||
searchIndex += 1
|
||
}
|
||
guard let matchedIndex = foundIndex else { return nil }
|
||
|
||
score += 90
|
||
if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) {
|
||
score += 140
|
||
}
|
||
if matchedIndex == previousMatch + 1 {
|
||
consecutiveRun += 1
|
||
score += min(200, consecutiveRun * 45)
|
||
} else {
|
||
consecutiveRun = 0
|
||
score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4)
|
||
}
|
||
|
||
previousMatch = matchedIndex
|
||
searchIndex = matchedIndex + 1
|
||
}
|
||
|
||
score -= max(0, candidateChars.count - tokenChars.count)
|
||
return max(1, score)
|
||
}
|
||
|
||
private static func subsequenceMatchIndices(token: String, candidate: String) -> Set<Int>? {
|
||
let tokenChars = Array(token)
|
||
let candidateChars = Array(candidate)
|
||
guard tokenChars.count <= candidateChars.count else { return nil }
|
||
|
||
var indices: Set<Int> = []
|
||
var searchIndex = 0
|
||
|
||
for tokenChar in tokenChars {
|
||
var foundIndex: Int?
|
||
while searchIndex < candidateChars.count {
|
||
if candidateChars[searchIndex] == tokenChar {
|
||
foundIndex = searchIndex
|
||
break
|
||
}
|
||
searchIndex += 1
|
||
}
|
||
guard let matchIndex = foundIndex else { return nil }
|
||
indices.insert(matchIndex)
|
||
searchIndex = matchIndex + 1
|
||
}
|
||
|
||
return indices
|
||
}
|
||
|
||
private static func initialismMatchIndices(token: String, candidate: String) -> Set<Int>? {
|
||
let tokenChars = Array(token)
|
||
let candidateChars = Array(candidate)
|
||
guard !tokenChars.isEmpty else { return nil }
|
||
|
||
let segments = wordSegments(candidateChars)
|
||
guard tokenChars.count <= segments.count else { return nil }
|
||
|
||
var matched: Set<Int> = []
|
||
var searchWordIndex = 0
|
||
|
||
for tokenChar in tokenChars {
|
||
var found = false
|
||
while searchWordIndex < segments.count {
|
||
let segment = segments[searchWordIndex]
|
||
searchWordIndex += 1
|
||
if candidateChars[segment.start] == tokenChar {
|
||
matched.insert(segment.start)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found { return nil }
|
||
}
|
||
|
||
return matched
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Match native titlebar behavior in the sidebar top strip:
|
||
// drag-to-move and double-click action (zoom/minimize).
|
||
WindowDragHandleView()
|
||
.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.dropContentTypes,
|
||
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.addWorkspace(placementOverride: .end)
|
||
if let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
}
|
||
selection = .tabs
|
||
}
|
||
.onDrop(of: SidebarTabDragPayload.dropContentTypes, 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(cmuxAccentColor())
|
||
.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
|
||
}
|
||
}
|
||
|
||
enum SidebarPathFormatter {
|
||
static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path
|
||
|
||
static func shortenedPath(
|
||
_ path: String,
|
||
homeDirectoryPath: String = Self.homeDirectoryPath
|
||
) -> String {
|
||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return path }
|
||
if trimmed == homeDirectoryPath {
|
||
return "~"
|
||
}
|
||
if trimmed.hasPrefix(homeDirectoryPath + "/") {
|
||
return "~" + trimmed.dropFirst(homeDirectoryPath.count)
|
||
}
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
enum SidebarWorkspaceShortcutHintMetrics {
|
||
private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold)
|
||
private static let minimumSlotWidth: CGFloat = 28
|
||
private static let horizontalPadding: CGFloat = 12
|
||
private static let lock = NSLock()
|
||
private static var cachedHintWidths: [String: CGFloat] = [:]
|
||
#if DEBUG
|
||
private static var measurementCount = 0
|
||
#endif
|
||
|
||
static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat {
|
||
guard let label else { return minimumSlotWidth }
|
||
let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2
|
||
return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset)
|
||
}
|
||
|
||
static func hintWidth(for label: String) -> CGFloat {
|
||
lock.lock()
|
||
if let cached = cachedHintWidths[label] {
|
||
lock.unlock()
|
||
return cached
|
||
}
|
||
lock.unlock()
|
||
|
||
let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width
|
||
let measuredWidth = ceil(textWidth) + horizontalPadding
|
||
|
||
lock.lock()
|
||
cachedHintWidths[label] = measuredWidth
|
||
#if DEBUG
|
||
measurementCount += 1
|
||
#endif
|
||
lock.unlock()
|
||
return measuredWidth
|
||
}
|
||
|
||
#if DEBUG
|
||
static func resetCacheForTesting() {
|
||
lock.lock()
|
||
cachedHintWidths.removeAll()
|
||
measurementCount = 0
|
||
lock.unlock()
|
||
}
|
||
|
||
static func measurementCountForTesting() -> Int {
|
||
lock.lock()
|
||
let count = measurementCount
|
||
lock.unlock()
|
||
return count
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private struct TabItemView: View {
|
||
@EnvironmentObject var tabManager: TabManager
|
||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
@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("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
|
||
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
||
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
|
||
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
|
||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||
private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||
|
||
var isActive: Bool {
|
||
tabManager.selectedTabId == tab.id
|
||
}
|
||
|
||
var isMultiSelected: Bool {
|
||
selectedTabIds.contains(tab.id)
|
||
}
|
||
|
||
private var isBeingDragged: Bool {
|
||
draggedTabId == tab.id
|
||
}
|
||
|
||
private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
|
||
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: activeTabIndicatorStyleRaw)
|
||
}
|
||
|
||
private var titleFontWeight: Font.Weight {
|
||
.semibold
|
||
}
|
||
|
||
private var showsLeadingRail: Bool {
|
||
explicitRailColor != nil
|
||
}
|
||
|
||
private var activeBorderLineWidth: CGFloat {
|
||
switch activeTabIndicatorStyle {
|
||
case .leftRail:
|
||
return 0
|
||
case .solidFill:
|
||
return isActive ? 1.5 : 0
|
||
}
|
||
}
|
||
|
||
private var activeBorderColor: Color {
|
||
guard isActive else { return .clear }
|
||
switch activeTabIndicatorStyle {
|
||
case .leftRail:
|
||
return .clear
|
||
case .solidFill:
|
||
return Color.primary.opacity(0.5)
|
||
}
|
||
}
|
||
|
||
private var usesInvertedActiveForeground: Bool {
|
||
isActive
|
||
}
|
||
|
||
private var activePrimaryTextColor: Color {
|
||
usesInvertedActiveForeground
|
||
? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0))
|
||
: .primary
|
||
}
|
||
|
||
private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color {
|
||
usesInvertedActiveForeground
|
||
? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity)))
|
||
: .secondary
|
||
}
|
||
|
||
private var activeUnreadBadgeFillColor: Color {
|
||
usesInvertedActiveForeground ? Color.white.opacity(0.25) : cmuxAccentColor()
|
||
}
|
||
|
||
private var activeProgressTrackColor: Color {
|
||
usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)
|
||
}
|
||
|
||
private var activeProgressFillColor: Color {
|
||
usesInvertedActiveForeground ? Color.white.opacity(0.8) : cmuxAccentColor()
|
||
}
|
||
|
||
private var shortcutHintEmphasis: Double {
|
||
usesInvertedActiveForeground ? 1.0 : 0.9
|
||
}
|
||
|
||
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 {
|
||
SidebarWorkspaceShortcutHintMetrics.slotWidth(
|
||
label: workspaceShortcutLabel,
|
||
debugXOffset: sidebarShortcutHintXOffset
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
let latestNotificationSubtitle = latestNotificationText
|
||
let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest)
|
||
? tab.sidebarOrderedPanelIds()
|
||
: nil
|
||
let compactGitBranchSummaryText: String? = {
|
||
guard sidebarShowBranchDirectory,
|
||
!sidebarBranchVerticalLayout,
|
||
sidebarShowGitBranch,
|
||
let orderedPanelIds else {
|
||
return nil
|
||
}
|
||
return gitBranchSummaryText(orderedPanelIds: orderedPanelIds)
|
||
}()
|
||
let compactDirectorySummaryText: String? = {
|
||
guard sidebarShowBranchDirectory,
|
||
!sidebarBranchVerticalLayout,
|
||
let orderedPanelIds else {
|
||
return nil
|
||
}
|
||
return directorySummaryText(orderedPanelIds: orderedPanelIds)
|
||
}()
|
||
let compactBranchDirectoryRow = branchDirectoryRow(
|
||
gitSummary: compactGitBranchSummaryText,
|
||
directorySummary: compactDirectorySummaryText
|
||
)
|
||
let branchDirectoryLines: [VerticalBranchDirectoryLine] = {
|
||
guard sidebarShowBranchDirectory,
|
||
sidebarBranchVerticalLayout,
|
||
let orderedPanelIds else {
|
||
return []
|
||
}
|
||
return verticalBranchDirectoryLines(orderedPanelIds: orderedPanelIds)
|
||
}()
|
||
let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil }
|
||
let pullRequestRows: [PullRequestDisplay] = {
|
||
guard sidebarShowPullRequest, let orderedPanelIds else { return [] }
|
||
return pullRequestDisplays(orderedPanelIds: orderedPanelIds)
|
||
}()
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 8) {
|
||
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
|
||
if unreadCount > 0 {
|
||
ZStack {
|
||
Circle()
|
||
.fill(activeUnreadBadgeFillColor)
|
||
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(activeSecondaryColor(0.8))
|
||
}
|
||
|
||
Text(tab.title)
|
||
.font(.system(size: 12.5, weight: titleFontWeight))
|
||
.foregroundColor(activePrimaryTextColor)
|
||
.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(activeSecondaryColor(0.7))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace"))
|
||
.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(activePrimaryTextColor)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis))
|
||
.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 = latestNotificationSubtitle {
|
||
Text(subtitle)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(activeSecondaryColor(0.8))
|
||
.lineLimit(2)
|
||
.truncationMode(.tail)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
|
||
if sidebarShowMetadata {
|
||
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
|
||
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
||
if !metadataEntries.isEmpty {
|
||
SidebarMetadataRows(
|
||
entries: metadataEntries,
|
||
isActive: usesInvertedActiveForeground,
|
||
onFocus: { updateSelection() }
|
||
)
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
if !metadataBlocks.isEmpty {
|
||
SidebarMetadataMarkdownBlocks(
|
||
blocks: metadataBlocks,
|
||
isActive: usesInvertedActiveForeground,
|
||
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: usesInvertedActiveForeground))
|
||
Text(latestLog.message)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(activeSecondaryColor(0.8))
|
||
.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(activeProgressTrackColor)
|
||
Capsule()
|
||
.fill(activeProgressFillColor)
|
||
.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(activeSecondaryColor(0.6))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
|
||
// Branch + directory row
|
||
if sidebarShowBranchDirectory {
|
||
if sidebarBranchVerticalLayout {
|
||
if !branchDirectoryLines.isEmpty {
|
||
HStack(alignment: .top, spacing: 3) {
|
||
if sidebarShowGitBranchIcon, branchLinesContainBranch {
|
||
Image(systemName: "arrow.triangle.branch")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(activeSecondaryColor(0.6))
|
||
}
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in
|
||
HStack(spacing: 3) {
|
||
if let branch = line.branch {
|
||
Text(branch)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(activeSecondaryColor(0.75))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
if line.branch != nil, line.directory != nil {
|
||
Image(systemName: "circle.fill")
|
||
.font(.system(size: 3))
|
||
.foregroundColor(activeSecondaryColor(0.6))
|
||
.padding(.horizontal, 1)
|
||
}
|
||
if let directory = line.directory {
|
||
Text(directory)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(activeSecondaryColor(0.75))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if let dirRow = compactBranchDirectoryRow {
|
||
HStack(spacing: 3) {
|
||
if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil {
|
||
Image(systemName: "arrow.triangle.branch")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(activeSecondaryColor(0.6))
|
||
}
|
||
Text(dirRow)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(activeSecondaryColor(0.75))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pull request rows
|
||
if sidebarShowPullRequest, !pullRequestRows.isEmpty {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
ForEach(pullRequestRows) { pullRequest in
|
||
Button(action: {
|
||
openPullRequestLink(pullRequest.url)
|
||
}) {
|
||
HStack(spacing: 4) {
|
||
PullRequestStatusIcon(
|
||
status: pullRequest.status,
|
||
color: pullRequestForegroundColor
|
||
)
|
||
Text("\(pullRequest.label) #\(pullRequest.number)")
|
||
.underline()
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Text(pullRequestStatusLabel(pullRequest.status))
|
||
.lineLimit(1)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(pullRequestForegroundColor)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Open \(pullRequest.label) #\(pullRequest.number)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Ports row
|
||
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
||
Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", "))
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(activeSecondaryColor(0.75))
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
|
||
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
|
||
.animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(backgroundColor)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth)
|
||
}
|
||
.overlay(alignment: .leading) {
|
||
if showsLeadingRail {
|
||
Capsule(style: .continuous)
|
||
.fill(railColor)
|
||
.frame(width: 3)
|
||
.padding(.leading, 4)
|
||
.padding(.vertical, 5)
|
||
.offset(x: -1)
|
||
}
|
||
}
|
||
)
|
||
.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(cmuxAccentColor())
|
||
.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.dropContentTypes, delegate: SidebarTabDropDelegate(
|
||
targetTabId: tab.id,
|
||
tabManager: tabManager,
|
||
draggedTabId: $draggedTabId,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||
targetRowHeight: rowHeight,
|
||
dragAutoScrollController: dragAutoScrollController,
|
||
dropIndicator: $dropIndicator
|
||
))
|
||
.onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate(
|
||
targetWorkspaceId: tab.id,
|
||
tabManager: tabManager,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
||
))
|
||
.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 tabColorPalette = WorkspaceTabColorSettings.palette()
|
||
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"
|
||
let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace)
|
||
let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace)
|
||
Button(pinLabel) {
|
||
for id in targetIds {
|
||
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
||
tabManager.setPinned(tab, pinned: shouldPin)
|
||
}
|
||
}
|
||
syncSelectionAfterMutation()
|
||
}
|
||
|
||
if let key = renameWorkspaceShortcut.keyEquivalent {
|
||
Button("Rename Workspace…") {
|
||
promptRename()
|
||
}
|
||
.keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers)
|
||
} else {
|
||
Button("Rename Workspace…") {
|
||
promptRename()
|
||
}
|
||
}
|
||
|
||
if tab.hasCustomTitle {
|
||
Button("Remove Custom Workspace Name") {
|
||
tabManager.clearCustomTitle(tabId: tab.id)
|
||
}
|
||
}
|
||
|
||
Menu("Workspace Color") {
|
||
if tab.customColor != nil {
|
||
Button {
|
||
applyTabColor(nil, targetIds: targetIds)
|
||
} label: {
|
||
Label("Clear Color", systemImage: "xmark.circle")
|
||
}
|
||
}
|
||
|
||
Button {
|
||
promptCustomColor(targetIds: targetIds)
|
||
} label: {
|
||
Label("Choose Custom Color…", systemImage: "paintpalette")
|
||
}
|
||
|
||
if !tabColorPalette.isEmpty {
|
||
Divider()
|
||
}
|
||
|
||
ForEach(tabColorPalette, id: \.id) { entry in
|
||
Button {
|
||
applyTabColor(entry.hex, targetIds: targetIds)
|
||
} label: {
|
||
Label {
|
||
Text(entry.name)
|
||
} icon: {
|
||
Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex)))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
|
||
let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager)
|
||
let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? []
|
||
let moveMenuTitle = targetIds.count > 1 ? "Move Workspaces to Window" : "Move Workspace to Window"
|
||
Menu(moveMenuTitle) {
|
||
Button("New Window") {
|
||
moveWorkspacesToNewWindow(targetIds)
|
||
}
|
||
.disabled(targetIds.isEmpty)
|
||
|
||
if !windowMoveTargets.isEmpty {
|
||
Divider()
|
||
}
|
||
|
||
ForEach(windowMoveTargets) { target in
|
||
Button(target.label) {
|
||
moveWorkspaces(targetIds, toWindow: target.windowId)
|
||
}
|
||
.disabled(target.isCurrentWindow || targetIds.isEmpty)
|
||
}
|
||
}
|
||
.disabled(targetIds.isEmpty)
|
||
|
||
Divider()
|
||
|
||
if let key = closeWorkspaceShortcut.keyEquivalent {
|
||
Button(closeLabel) {
|
||
closeTabs(targetIds, allowPinned: true)
|
||
}
|
||
.keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers)
|
||
.disabled(targetIds.isEmpty)
|
||
} else {
|
||
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 {
|
||
switch activeTabIndicatorStyle {
|
||
case .leftRail:
|
||
if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) }
|
||
if isMultiSelected { return cmuxAccentColor().opacity(0.25) }
|
||
return Color.clear
|
||
case .solidFill:
|
||
if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) }
|
||
if let custom = resolvedCustomTabColor {
|
||
if isMultiSelected { return custom.opacity(0.35) }
|
||
return custom.opacity(0.7)
|
||
}
|
||
if isMultiSelected { return cmuxAccentColor().opacity(0.25) }
|
||
return Color.clear
|
||
}
|
||
}
|
||
|
||
private var railColor: Color {
|
||
explicitRailColor ?? .clear
|
||
}
|
||
|
||
private var explicitRailColor: Color? {
|
||
guard activeTabIndicatorStyle == .leftRail,
|
||
let custom = resolvedCustomTabColor else {
|
||
return nil
|
||
}
|
||
return custom.opacity(0.95)
|
||
}
|
||
|
||
private var resolvedCustomTabColor: Color? {
|
||
guard let hex = tab.customColor else { return nil }
|
||
return WorkspaceTabColorSettings.displayColor(
|
||
hex: hex,
|
||
colorScheme: colorScheme,
|
||
forceBright: activeTabIndicatorStyle == .leftRail
|
||
)
|
||
}
|
||
|
||
private func tabColorSwatchColor(for hex: String) -> NSColor {
|
||
WorkspaceTabColorSettings.displayNSColor(
|
||
hex: hex,
|
||
colorScheme: colorScheme,
|
||
forceBright: activeTabIndicatorStyle == .leftRail
|
||
) ?? NSColor(hex: hex) ?? .gray
|
||
}
|
||
|
||
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 func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) {
|
||
guard let app = AppDelegate.shared else { return }
|
||
let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil }
|
||
guard !orderedWorkspaceIds.isEmpty else { return }
|
||
|
||
for (index, workspaceId) in orderedWorkspaceIds.enumerated() {
|
||
let shouldFocus = index == orderedWorkspaceIds.count - 1
|
||
_ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus)
|
||
}
|
||
|
||
selectedTabIds.subtract(orderedWorkspaceIds)
|
||
syncSelectionAfterMutation()
|
||
}
|
||
|
||
private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) {
|
||
guard let app = AppDelegate.shared else { return }
|
||
let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil }
|
||
guard let firstWorkspaceId = orderedWorkspaceIds.first else { return }
|
||
|
||
let shouldFocusImmediately = orderedWorkspaceIds.count == 1
|
||
guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else {
|
||
return
|
||
}
|
||
|
||
if orderedWorkspaceIds.count > 1 {
|
||
for workspaceId in orderedWorkspaceIds.dropFirst() {
|
||
_ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false)
|
||
}
|
||
if let finalWorkspaceId = orderedWorkspaceIds.last {
|
||
_ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true)
|
||
}
|
||
}
|
||
|
||
selectedTabIds.subtract(orderedWorkspaceIds)
|
||
syncSelectionAfterMutation()
|
||
}
|
||
|
||
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 func branchDirectoryRow(
|
||
gitSummary: String?,
|
||
directorySummary: String?
|
||
) -> String? {
|
||
var parts: [String] = []
|
||
|
||
if let gitSummary {
|
||
parts.append(gitSummary)
|
||
}
|
||
|
||
if let directorySummary {
|
||
parts.append(directorySummary)
|
||
}
|
||
|
||
let result = parts.joined(separator: " · ")
|
||
return result.isEmpty ? nil : result
|
||
}
|
||
|
||
private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? {
|
||
let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds)
|
||
guard !lines.isEmpty else { return nil }
|
||
return lines.joined(separator: " | ")
|
||
}
|
||
|
||
private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] {
|
||
tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in
|
||
"\(branch.branch)\(branch.isDirty ? "*" : "")"
|
||
}
|
||
}
|
||
|
||
private struct VerticalBranchDirectoryLine {
|
||
let branch: String?
|
||
let directory: String?
|
||
}
|
||
|
||
private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] {
|
||
let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds)
|
||
let home = SidebarPathFormatter.homeDirectoryPath
|
||
return entries.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 = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: 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 func directorySummaryText(orderedPanelIds: [UUID]) -> String? {
|
||
guard !tab.panels.isEmpty else { return nil }
|
||
let home = SidebarPathFormatter.homeDirectoryPath
|
||
var seen: Set<String> = []
|
||
var entries: [String] = []
|
||
for panelId in orderedPanelIds {
|
||
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
|
||
let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home)
|
||
guard !shortened.isEmpty else { continue }
|
||
if seen.insert(shortened).inserted {
|
||
entries.append(shortened)
|
||
}
|
||
}
|
||
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
||
}
|
||
|
||
private struct PullRequestDisplay: Identifiable {
|
||
let id: String
|
||
let number: Int
|
||
let label: String
|
||
let url: URL
|
||
let status: SidebarPullRequestStatus
|
||
}
|
||
|
||
private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] {
|
||
tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in
|
||
PullRequestDisplay(
|
||
id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)",
|
||
number: pullRequest.number,
|
||
label: pullRequest.label,
|
||
url: pullRequest.url,
|
||
status: pullRequest.status
|
||
)
|
||
}
|
||
}
|
||
|
||
private var pullRequestForegroundColor: Color {
|
||
isActive ? .white.opacity(0.75) : .secondary
|
||
}
|
||
|
||
private func openPullRequestLink(_ url: URL) {
|
||
updateSelection()
|
||
if openSidebarPullRequestLinksInCmuxBrowser {
|
||
if tabManager.openBrowser(
|
||
inWorkspace: tab.id,
|
||
url: url,
|
||
preferSplitRight: true,
|
||
insertAtEnd: true
|
||
) == nil {
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
return
|
||
}
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
|
||
private func pullRequestStatusLabel(_ status: SidebarPullRequestStatus) -> String {
|
||
switch status {
|
||
case .open: return "open"
|
||
case .merged: return "merged"
|
||
case .closed: return "closed"
|
||
}
|
||
}
|
||
|
||
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 Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5))
|
||
case .progress:
|
||
return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8))
|
||
case .success:
|
||
return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9))
|
||
case .warning:
|
||
return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9))
|
||
case .error:
|
||
return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(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 struct PullRequestStatusIcon: View {
|
||
let status: SidebarPullRequestStatus
|
||
let color: Color
|
||
private static let frameSize: CGFloat = 12
|
||
|
||
var body: some View {
|
||
switch status {
|
||
case .open:
|
||
PullRequestOpenIcon(color: color)
|
||
case .merged:
|
||
PullRequestMergedIcon(color: color)
|
||
case .closed:
|
||
Image(systemName: "xmark.circle")
|
||
.font(.system(size: 7, weight: .regular))
|
||
.foregroundColor(color)
|
||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct PullRequestOpenIcon: View {
|
||
let color: Color
|
||
private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round)
|
||
private static let nodeDiameter: CGFloat = 3.0
|
||
private static let frameSize: CGFloat = 13
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Path { path in
|
||
path.move(to: CGPoint(x: 3.0, y: 4.8))
|
||
path.addLine(to: CGPoint(x: 3.0, y: 9.2))
|
||
|
||
path.move(to: CGPoint(x: 4.8, y: 3.0))
|
||
path.addLine(to: CGPoint(x: 9.4, y: 3.0))
|
||
path.addLine(to: CGPoint(x: 11.0, y: 4.6))
|
||
path.addLine(to: CGPoint(x: 11.0, y: 9.2))
|
||
}
|
||
.stroke(color, style: Self.stroke)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 3.0, y: 3.0)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 3.0, y: 11.0)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 11.0, y: 11.0)
|
||
}
|
||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||
}
|
||
}
|
||
|
||
private struct PullRequestMergedIcon: View {
|
||
let color: Color
|
||
private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round)
|
||
private static let nodeDiameter: CGFloat = 3.0
|
||
private static let frameSize: CGFloat = 13
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Path { path in
|
||
path.move(to: CGPoint(x: 4.6, y: 4.6))
|
||
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
|
||
path.addLine(to: CGPoint(x: 9.2, y: 7.0))
|
||
|
||
path.move(to: CGPoint(x: 4.6, y: 9.4))
|
||
path.addLine(to: CGPoint(x: 7.1, y: 7.0))
|
||
}
|
||
.stroke(color, style: Self.stroke)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 3.0, y: 3.0)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 3.0, y: 11.0)
|
||
|
||
Circle()
|
||
.stroke(color, lineWidth: Self.stroke.lineWidth)
|
||
.frame(width: Self.nodeDiameter, height: Self.nodeDiameter)
|
||
.position(x: 11.0, y: 7.0)
|
||
}
|
||
.frame(width: Self.frameSize, height: Self.frameSize)
|
||
}
|
||
}
|
||
|
||
private func applyTabColor(_ hex: String?, targetIds: [UUID]) {
|
||
for targetId in targetIds {
|
||
tabManager.setTabColor(tabId: targetId, color: hex)
|
||
}
|
||
}
|
||
|
||
private func promptCustomColor(targetIds: [UUID]) {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Custom Workspace Color"
|
||
alert.informativeText = "Enter a hex color in the format #RRGGBB."
|
||
|
||
let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? ""
|
||
let input = NSTextField(string: seed)
|
||
input.placeholderString = "#1565C0"
|
||
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
||
alert.accessoryView = input
|
||
alert.addButton(withTitle: "Apply")
|
||
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 }
|
||
guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else {
|
||
showInvalidColorAlert(input.stringValue)
|
||
return
|
||
}
|
||
applyTabColor(normalized, targetIds: targetIds)
|
||
}
|
||
|
||
private func showInvalidColorAlert(_ value: String) {
|
||
let alert = NSAlert()
|
||
alert.alertStyle = .warning
|
||
alert.messageText = "Invalid Color"
|
||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
alert.informativeText = "Enter a hex color in the format #RRGGBB."
|
||
} else {
|
||
alert.informativeText = "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB."
|
||
}
|
||
alert.addButton(withTitle: "OK")
|
||
_ = alert.runModal()
|
||
}
|
||
|
||
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 SidebarMetadataRows: View {
|
||
let entries: [SidebarStatusEntry]
|
||
let isActive: Bool
|
||
let onFocus: () -> Void
|
||
|
||
@State private var isExpanded: Bool = false
|
||
private let collapsedEntryLimit = 3
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
ForEach(visibleEntries, id: \.key) { entry in
|
||
SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus)
|
||
}
|
||
|
||
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 ? activeSecondaryTextColor : .secondary.opacity(0.9))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.help(helpText)
|
||
}
|
||
|
||
private var activeSecondaryTextColor: Color {
|
||
Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65))
|
||
}
|
||
|
||
private var visibleEntries: [SidebarStatusEntry] {
|
||
guard !isExpanded, entries.count > collapsedEntryLimit else { return entries }
|
||
return Array(entries.prefix(collapsedEntryLimit))
|
||
}
|
||
|
||
private var helpText: String {
|
||
entries.map { entry in
|
||
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? entry.key : trimmed
|
||
}
|
||
.joined(separator: "\n")
|
||
}
|
||
|
||
private var shouldShowToggle: Bool {
|
||
entries.count > collapsedEntryLimit
|
||
}
|
||
}
|
||
|
||
private struct SidebarMetadataEntryRow: View {
|
||
let entry: SidebarStatusEntry
|
||
let isActive: Bool
|
||
let onFocus: () -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let url = entry.url {
|
||
Button {
|
||
onFocus()
|
||
NSWorkspace.shared.open(url)
|
||
} label: {
|
||
rowContent(underlined: true)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(url.absoluteString)
|
||
} else {
|
||
rowContent(underlined: false)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { onFocus() }
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func rowContent(underlined: Bool) -> some View {
|
||
HStack(spacing: 4) {
|
||
if let icon = iconView {
|
||
icon
|
||
.foregroundColor(foregroundColor.opacity(0.95))
|
||
}
|
||
metadataText(underlined: underlined)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.font(.system(size: 10))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
private var foregroundColor: Color {
|
||
if isActive,
|
||
let raw = entry.color,
|
||
Color(hex: raw) != nil {
|
||
return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95))
|
||
}
|
||
if let raw = entry.color, let explicit = Color(hex: raw) {
|
||
return explicit
|
||
}
|
||
return isActive ? .white.opacity(0.8) : .secondary
|
||
}
|
||
|
||
private var iconView: AnyView? {
|
||
guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!iconRaw.isEmpty else {
|
||
return nil
|
||
}
|
||
if iconRaw.hasPrefix("emoji:") {
|
||
let value = String(iconRaw.dropFirst("emoji:".count))
|
||
guard !value.isEmpty else { return nil }
|
||
return AnyView(Text(value).font(.system(size: 9)))
|
||
}
|
||
if iconRaw.hasPrefix("text:") {
|
||
let value = String(iconRaw.dropFirst("text:".count))
|
||
guard !value.isEmpty else { return nil }
|
||
return AnyView(Text(value).font(.system(size: 8, weight: .semibold)))
|
||
}
|
||
let symbolName: String
|
||
if iconRaw.hasPrefix("sf:") {
|
||
symbolName = String(iconRaw.dropFirst("sf:".count))
|
||
} else {
|
||
symbolName = iconRaw
|
||
}
|
||
guard !symbolName.isEmpty else { return nil }
|
||
return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium)))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func metadataText(underlined: Bool) -> some View {
|
||
let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let display = trimmed.isEmpty ? entry.key : trimmed
|
||
if entry.format == .markdown,
|
||
let attributed = try? AttributedString(
|
||
markdown: display,
|
||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||
) {
|
||
Text(attributed)
|
||
.underline(underlined)
|
||
.foregroundColor(foregroundColor)
|
||
} else {
|
||
Text(display)
|
||
.underline(underlined)
|
||
.foregroundColor(foregroundColor)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SidebarMetadataMarkdownBlocks: View {
|
||
let blocks: [SidebarMetadataBlock]
|
||
let isActive: Bool
|
||
let onFocus: () -> Void
|
||
|
||
@State private var isExpanded: Bool = false
|
||
private let collapsedBlockLimit = 1
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
ForEach(visibleBlocks, id: \.key) { block in
|
||
SidebarMetadataMarkdownBlockRow(
|
||
block: block,
|
||
isActive: isActive,
|
||
onFocus: onFocus
|
||
)
|
||
}
|
||
|
||
if shouldShowToggle {
|
||
Button(isExpanded ? "Show less details" : "Show more details") {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var visibleBlocks: [SidebarMetadataBlock] {
|
||
guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks }
|
||
return Array(blocks.prefix(collapsedBlockLimit))
|
||
}
|
||
|
||
private var shouldShowToggle: Bool {
|
||
blocks.count > collapsedBlockLimit
|
||
}
|
||
}
|
||
|
||
private struct SidebarMetadataMarkdownBlockRow: View {
|
||
let block: SidebarMetadataBlock
|
||
let isActive: Bool
|
||
let onFocus: () -> Void
|
||
|
||
@State private var renderedMarkdown: AttributedString?
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let renderedMarkdown {
|
||
Text(renderedMarkdown)
|
||
.foregroundColor(foregroundColor)
|
||
} else {
|
||
Text(block.markdown)
|
||
.foregroundColor(foregroundColor)
|
||
}
|
||
}
|
||
.font(.system(size: 10))
|
||
.multilineTextAlignment(.leading)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { onFocus() }
|
||
.onAppear(perform: renderMarkdown)
|
||
.onChange(of: block.markdown) { _ in
|
||
renderMarkdown()
|
||
}
|
||
}
|
||
|
||
private var foregroundColor: Color {
|
||
isActive ? .white.opacity(0.8) : .secondary
|
||
}
|
||
|
||
private func renderMarkdown() {
|
||
renderedMarkdown = try? AttributedString(
|
||
markdown: block.markdown,
|
||
options: .init(interpretedSyntax: .full)
|
||
)
|
||
}
|
||
}
|
||
|
||
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"
|
||
static let dropContentType = UTType(exportedAs: typeIdentifier)
|
||
static let dropContentTypes: [UTType] = [dropContentType]
|
||
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 enum BonsplitTabDragPayload {
|
||
static let typeIdentifier = "com.splittabbar.tabtransfer"
|
||
static let dropContentType = UTType(exportedAs: typeIdentifier)
|
||
static let dropContentTypes: [UTType] = [dropContentType]
|
||
private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier)
|
||
|
||
struct Transfer: Decodable {
|
||
struct TabInfo: Decodable {
|
||
let id: UUID
|
||
}
|
||
|
||
let tab: TabInfo
|
||
let sourcePaneId: UUID
|
||
let sourceProcessId: Int32
|
||
|
||
private enum CodingKeys: String, CodingKey {
|
||
case tab
|
||
case sourcePaneId
|
||
case sourceProcessId
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.tab = try container.decode(TabInfo.self, forKey: .tab)
|
||
self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId)
|
||
// Legacy payloads won't include this field. Treat as foreign process.
|
||
self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1
|
||
}
|
||
}
|
||
|
||
private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool {
|
||
transfer.sourceProcessId == currentProcessId
|
||
}
|
||
|
||
static func currentTransfer() -> Transfer? {
|
||
let pasteboard = NSPasteboard(name: .drag)
|
||
let type = NSPasteboard.PasteboardType(typeIdentifier)
|
||
|
||
if let data = pasteboard.data(forType: type),
|
||
let transfer = try? JSONDecoder().decode(Transfer.self, from: data),
|
||
isCurrentProcessTransfer(transfer) {
|
||
return transfer
|
||
}
|
||
|
||
if let raw = pasteboard.string(forType: type),
|
||
let data = raw.data(using: .utf8),
|
||
let transfer = try? JSONDecoder().decode(Transfer.self, from: data),
|
||
isCurrentProcessTransfer(transfer) {
|
||
return transfer
|
||
}
|
||
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private struct SidebarBonsplitTabDropDelegate: DropDelegate {
|
||
let targetWorkspaceId: UUID
|
||
let tabManager: TabManager
|
||
@Binding var selectedTabIds: Set<UUID>
|
||
@Binding var lastSidebarSelectionIndex: Int?
|
||
|
||
func validateDrop(info: DropInfo) -> Bool {
|
||
guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false }
|
||
return BonsplitTabDragPayload.currentTransfer() != nil
|
||
}
|
||
|
||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||
guard validateDrop(info: info) else { return nil }
|
||
return DropProposal(operation: .move)
|
||
}
|
||
|
||
func performDrop(info: DropInfo) -> Bool {
|
||
guard validateDrop(info: info),
|
||
let transfer = BonsplitTabDragPayload.currentTransfer(),
|
||
let app = AppDelegate.shared else {
|
||
return false
|
||
}
|
||
|
||
if let source = app.locateBonsplitSurface(tabId: transfer.tab.id),
|
||
source.workspaceId == targetWorkspaceId {
|
||
syncSidebarSelection()
|
||
return true
|
||
}
|
||
|
||
guard app.moveBonsplitTab(
|
||
tabId: transfer.tab.id,
|
||
toWorkspace: targetWorkspaceId,
|
||
focus: true,
|
||
focusWindow: true
|
||
) else {
|
||
return false
|
||
}
|
||
|
||
selectedTabIds = [targetWorkspaceId]
|
||
syncSidebarSelection()
|
||
return true
|
||
}
|
||
|
||
private func syncSidebarSelection() {
|
||
if let selectedId = tabManager.selectedTabId {
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
} else {
|
||
lastSidebarSelectionIndex = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
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")"
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||
private final class FolderIconImageView: NSImageView {
|
||
override var mouseDownCanMoveWindow: Bool { false }
|
||
}
|
||
|
||
var directory: String
|
||
private var imageView: FolderIconImageView!
|
||
private var previousWindowMovableState: Bool?
|
||
private weak var suppressedWindow: NSWindow?
|
||
private var hasActiveDragSession = false
|
||
private var didArmWindowDragSuppression = false
|
||
|
||
private func formatPoint(_ point: NSPoint) -> String {
|
||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
override var mouseDownCanMoveWindow: Bool { false }
|
||
|
||
private func setupImageView() {
|
||
imageView = FolderIconImageView()
|
||
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
|
||
}
|
||
|
||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||
hasActiveDragSession = false
|
||
restoreWindowMovableStateIfNeeded()
|
||
#if DEBUG
|
||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||
dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||
#endif
|
||
}
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
guard bounds.contains(point) else { return nil }
|
||
let hit = super.hitTest(point)
|
||
#if DEBUG
|
||
let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil"
|
||
let imageHit = (hit === imageView)
|
||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||
dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)")
|
||
#endif
|
||
return self
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
maybeDisableWindowDraggingEarly(trigger: "mouseDown")
|
||
hasActiveDragSession = false
|
||
#if DEBUG
|
||
let localPoint = convert(event.locationInWindow, from: nil)
|
||
let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||
dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||
#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)
|
||
|
||
let session = beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||
hasActiveDragSession = true
|
||
#if DEBUG
|
||
let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0
|
||
dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)")
|
||
#endif
|
||
}
|
||
|
||
override func mouseUp(with event: NSEvent) {
|
||
super.mouseUp(with: event)
|
||
// Always restore suppression on mouse-up; drag-session callbacks can be
|
||
// skipped for non-started drags, which would otherwise leave suppression stuck.
|
||
restoreWindowMovableStateIfNeeded()
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
private func restoreWindowMovableStateIfNeeded() {
|
||
guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return }
|
||
let targetWindow = suppressedWindow ?? window
|
||
let depthAfter = endWindowDragSuppression(window: targetWindow)
|
||
restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState)
|
||
self.previousWindowMovableState = nil
|
||
self.suppressedWindow = nil
|
||
self.didArmWindowDragSuppression = false
|
||
#if DEBUG
|
||
let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil"
|
||
dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)")
|
||
#endif
|
||
}
|
||
|
||
private func maybeDisableWindowDraggingEarly(trigger: String) {
|
||
guard !didArmWindowDragSuppression else { return }
|
||
guard let eventType = NSApp.currentEvent?.type,
|
||
eventType == .leftMouseDown || eventType == .leftMouseDragged else {
|
||
return
|
||
}
|
||
guard let currentWindow = window else { return }
|
||
|
||
didArmWindowDragSuppression = true
|
||
suppressedWindow = currentWindow
|
||
let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0
|
||
if currentWindow.isMovable {
|
||
previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow)
|
||
} else {
|
||
previousWindowMovableState = nil
|
||
}
|
||
#if DEBUG
|
||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||
let nowMovable = String(currentWindow.isMovable)
|
||
dlog(
|
||
"folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)"
|
||
)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? {
|
||
guard let window else { return nil }
|
||
let wasMovable = window.isMovable
|
||
if wasMovable {
|
||
window.isMovable = false
|
||
}
|
||
return wasMovable
|
||
}
|
||
|
||
func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) {
|
||
guard let window, let previousMovableState else { return }
|
||
window.isMovable = previousMovableState
|
||
}
|
||
|
||
/// 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.
|
||
final class TitlebarLeadingInsetPassthroughView: NSView {
|
||
override var mouseDownCanMoveWindow: Bool { false }
|
||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||
}
|
||
|
||
private struct TitlebarLeadingInsetReader: NSViewRepresentable {
|
||
@Binding var inset: CGFloat
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = TitlebarLeadingInsetPassthroughView()
|
||
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(includeAlpha: Bool = false) -> 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)
|
||
let redByte = min(255, max(0, Int(red * 255)))
|
||
let greenByte = min(255, max(0, Int(green * 255)))
|
||
let blueByte = min(255, max(0, Int(blue * 255)))
|
||
if includeAlpha {
|
||
let alphaByte = min(255, max(0, Int(alpha * 255)))
|
||
return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte)
|
||
}
|
||
return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte)
|
||
}
|
||
}
|