* Sidebar ports on own line, wider sidebar, CMUX_PORT env vars - Move listening ports to dedicated sidebar row (removed from branch/directory line) - Allow sidebar to resize up to 2/3 of screen width (was capped at 360px) - Add CMUX_PORT, CMUX_PORT_END, CMUX_PORT_RANGE env vars per workspace - Each workspace gets a dedicated port range (default: base 9100, range 10) - Add settings UI for port base and range size - Add portOrdinal to Workspace, monotonic counter in TabManager Closes #129 * Make port ordinal counter static to avoid overlap across windows Each window creates its own TabManager, so a per-instance counter would reset and reuse port ranges. Making it static ensures unique ranges across all windows. * Fix portOrdinal race: pass through Workspace init instead of setting after The first TerminalPanel is created inside Workspace.init, so setting portOrdinal after init returns meant the initial terminal always got ordinal 0. Pass portOrdinal as an init parameter and set it before the TerminalPanel is created. * Fix P2/P3: snapshot port settings at surface creation, use window screen for sidebar cap P2: Port base/range are now snapshotted on TerminalSurface when the panel is created, so changing settings mid-session won't cause inconsistent CMUX_PORT values across terminals in the same workspace. P3: Sidebar max width now uses NSApp.keyWindow?.screen instead of NSScreen.main, so multi-monitor setups get the correct 2/3 cap for the display the window is actually on. * Fix P1: snapshot port base/range once per app session, not per panel Port base and range size are now static properties on TerminalSurface, initialized once from UserDefaults at first access. This prevents overlapping port ranges across workspaces when settings are changed mid-session (e.g., workspace 1 with range=10 at 9110-9119, then range changed to 5, workspace 2 would overlap at 9110).
3096 lines
120 KiB
Swift
3096 lines
120 KiB
Swift
import AppKit
|
||
import Bonsplit
|
||
import SwiftUI
|
||
import ObjectiveC
|
||
import UniformTypeIdentifiers
|
||
|
||
struct ShortcutHintPillBackground: View {
|
||
var emphasis: Double = 1.0
|
||
|
||
var body: some View {
|
||
Capsule(style: .continuous)
|
||
.fill(.regularMaterial)
|
||
.overlay(
|
||
Capsule(style: .continuous)
|
||
.stroke(Color.white.opacity(0.30 * emphasis), lineWidth: 0.8)
|
||
)
|
||
.shadow(color: Color.black.opacity(0.22 * emphasis), radius: 2, x: 0, y: 1)
|
||
}
|
||
}
|
||
|
||
/// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView
|
||
enum WindowGlassEffect {
|
||
private static var glassViewKey: UInt8 = 0
|
||
private static var tintOverlayKey: UInt8 = 0
|
||
|
||
static var isAvailable: Bool {
|
||
NSClassFromString("NSGlassEffectView") != nil
|
||
}
|
||
|
||
static func apply(to window: NSWindow, tintColor: NSColor? = nil) {
|
||
guard let originalContentView = window.contentView else { return }
|
||
|
||
// Check if we already applied glass (avoid re-wrapping)
|
||
if let existingGlass = objc_getAssociatedObject(window, &glassViewKey) as? NSView {
|
||
// Already applied, just update the tint
|
||
updateTint(on: existingGlass, color: tintColor, window: window)
|
||
return
|
||
}
|
||
|
||
let bounds = originalContentView.bounds
|
||
|
||
// Create the glass/blur view
|
||
let glassView: NSVisualEffectView
|
||
let usingGlassEffectView: Bool
|
||
|
||
// Try NSGlassEffectView first (macOS 26 Tahoe+)
|
||
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type {
|
||
usingGlassEffectView = true
|
||
glassView = glassClass.init(frame: bounds)
|
||
glassView.wantsLayer = true
|
||
glassView.layer?.cornerRadius = 0
|
||
|
||
// Apply tint color via private API
|
||
if let color = tintColor {
|
||
let selector = NSSelectorFromString("setTintColor:")
|
||
if glassView.responds(to: selector) {
|
||
glassView.perform(selector, with: color)
|
||
}
|
||
}
|
||
} else {
|
||
usingGlassEffectView = false
|
||
// Fallback to NSVisualEffectView
|
||
glassView = NSVisualEffectView(frame: bounds)
|
||
glassView.blendingMode = .behindWindow
|
||
// Favor a lighter fallback so behind-window glass reads more transparent.
|
||
glassView.material = .underWindowBackground
|
||
glassView.state = .active
|
||
glassView.wantsLayer = true
|
||
}
|
||
|
||
glassView.autoresizingMask = [.width, .height]
|
||
|
||
if usingGlassEffectView {
|
||
// NSGlassEffectView is a full replacement for the contentView.
|
||
window.contentView = glassView
|
||
|
||
// Re-add the original SwiftUI hosting view on top of the glass, filling entire area.
|
||
originalContentView.translatesAutoresizingMaskIntoConstraints = false
|
||
originalContentView.wantsLayer = true
|
||
originalContentView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
glassView.addSubview(originalContentView)
|
||
|
||
NSLayoutConstraint.activate([
|
||
originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor),
|
||
originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor),
|
||
originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor),
|
||
originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor)
|
||
])
|
||
} else {
|
||
// For NSVisualEffectView fallback (macOS 13-15), do NOT replace window.contentView.
|
||
// Replacing contentView can break traffic light rendering with
|
||
// `.fullSizeContentView` + `titlebarAppearsTransparent`.
|
||
glassView.translatesAutoresizingMaskIntoConstraints = false
|
||
originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil)
|
||
|
||
NSLayoutConstraint.activate([
|
||
glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor),
|
||
glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor),
|
||
glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor),
|
||
glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor)
|
||
])
|
||
}
|
||
|
||
// Add tint overlay between glass and content (for fallback)
|
||
if let tintColor, !usingGlassEffectView {
|
||
let tintOverlay = NSView(frame: bounds)
|
||
tintOverlay.translatesAutoresizingMaskIntoConstraints = false
|
||
tintOverlay.wantsLayer = true
|
||
tintOverlay.layer?.backgroundColor = tintColor.cgColor
|
||
glassView.addSubview(tintOverlay)
|
||
NSLayoutConstraint.activate([
|
||
tintOverlay.topAnchor.constraint(equalTo: glassView.topAnchor),
|
||
tintOverlay.bottomAnchor.constraint(equalTo: glassView.bottomAnchor),
|
||
tintOverlay.leadingAnchor.constraint(equalTo: glassView.leadingAnchor),
|
||
tintOverlay.trailingAnchor.constraint(equalTo: glassView.trailingAnchor)
|
||
])
|
||
objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
|
||
// Store reference
|
||
objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
|
||
/// Update the tint color on an existing glass effect
|
||
static func updateTint(to window: NSWindow, color: NSColor?) {
|
||
guard let glassView = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { return }
|
||
updateTint(on: glassView, color: color, window: window)
|
||
}
|
||
|
||
private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) {
|
||
// For NSGlassEffectView, use setTintColor:
|
||
if glassView.className == "NSGlassEffectView" {
|
||
let selector = NSSelectorFromString("setTintColor:")
|
||
if glassView.responds(to: selector) {
|
||
glassView.perform(selector, with: color)
|
||
}
|
||
} else {
|
||
// For NSVisualEffectView fallback, update the tint overlay
|
||
if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView {
|
||
tintOverlay.layer?.backgroundColor = color?.cgColor
|
||
}
|
||
}
|
||
}
|
||
|
||
static func remove(from window: NSWindow) {
|
||
// Note: Removing would require restoring original contentView structure
|
||
// For now, just clear the reference
|
||
objc_setAssociatedObject(window, &glassViewKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
||
objc_setAssociatedObject(window, &tintOverlayKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
||
}
|
||
}
|
||
|
||
final class SidebarState: ObservableObject {
|
||
@Published var isVisible: Bool = true
|
||
|
||
func toggle() {
|
||
isVisible.toggle()
|
||
}
|
||
}
|
||
|
||
// MARK: - File Drop Overlay
|
||
|
||
/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to
|
||
/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's
|
||
/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply
|
||
/// embedded terminal views. This overlay sits above the entire content view hierarchy and
|
||
/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor.
|
||
///
|
||
/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks,
|
||
/// scrolls, and other interactions pass through normally.
|
||
final class FileDropOverlayView: NSView {
|
||
/// Fallback handler when no terminal is found under the drop point.
|
||
var onDrop: (([URL]) -> Bool)?
|
||
|
||
override var acceptsFirstResponder: Bool { false }
|
||
|
||
override init(frame frameRect: NSRect) {
|
||
super.init(frame: frameRect)
|
||
registerForDraggedTypes([.fileURL])
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
|
||
|
||
// MARK: Hit-testing — only participate when the system drag pasteboard contains file
|
||
// URLs (i.e. a Finder file drag is in progress). For everything else — mouse events,
|
||
// sidebar tab reorder, bonsplit tab drags — return nil so events route to the content
|
||
// view below and SwiftUI / bonsplit drag-and-drop works normally.
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
let pb = NSPasteboard(name: .drag)
|
||
guard let types = pb.types, types.contains(.fileURL) else { return nil }
|
||
|
||
// The drag pasteboard can retain stale file types after a completed drag.
|
||
// Only participate during active drag-motion events.
|
||
let eventType = NSApp.currentEvent?.type
|
||
let isDragMouseEvent = eventType == .leftMouseDragged
|
||
|| eventType == .rightMouseDragged
|
||
|| eventType == .otherMouseDragged
|
||
guard isDragMouseEvent 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 let window, let contentView = window.contentView else { return }
|
||
isHidden = true
|
||
let point = contentView.convert(event.locationInWindow, from: nil)
|
||
let target = contentView.hitTest(point)
|
||
isHidden = false
|
||
guard let target else { return }
|
||
|
||
switch event.type {
|
||
case .leftMouseDown: target.mouseDown(with: event)
|
||
case .leftMouseUp: target.mouseUp(with: event)
|
||
case .leftMouseDragged: target.mouseDragged(with: event)
|
||
case .rightMouseDown: target.rightMouseDown(with: event)
|
||
case .rightMouseUp: target.rightMouseUp(with: event)
|
||
case .rightMouseDragged: target.rightMouseDragged(with: event)
|
||
case .otherMouseDown: target.otherMouseDown(with: event)
|
||
case .otherMouseUp: target.otherMouseUp(with: event)
|
||
case .otherMouseDragged: target.otherMouseDragged(with: event)
|
||
case .scrollWheel: target.scrollWheel(with: event)
|
||
default: break
|
||
}
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) { forwardEvent(event) }
|
||
override func mouseUp(with event: NSEvent) { forwardEvent(event) }
|
||
override func mouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||
override func rightMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||
override func rightMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||
override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||
override func otherMouseDown(with event: NSEvent) { forwardEvent(event) }
|
||
override func otherMouseUp(with event: NSEvent) { forwardEvent(event) }
|
||
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
|
||
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
|
||
|
||
// MARK: NSDraggingDestination – only accept file drops over terminal views.
|
||
|
||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||
return dragOperationForSender(sender)
|
||
}
|
||
|
||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||
return dragOperationForSender(sender)
|
||
}
|
||
|
||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||
guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false }
|
||
return terminal.performDragOperation(sender)
|
||
}
|
||
|
||
private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||
guard let types = sender.draggingPasteboard.types,
|
||
types.contains(.fileURL),
|
||
terminalUnderPoint(sender.draggingLocation) != nil else {
|
||
return []
|
||
}
|
||
return .copy
|
||
}
|
||
|
||
/// 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
|
||
let point = contentView.convert(windowPoint, from: nil)
|
||
let hitView = contentView.hitTest(point)
|
||
isHidden = false
|
||
|
||
var current: NSView? = hitView
|
||
while let view = current {
|
||
if let terminal = view as? GhosttyNSView { return terminal }
|
||
current = view.superview
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
var fileDropOverlayKey: UInt8 = 0
|
||
|
||
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 sidebarMinX: CGFloat = 0
|
||
@State private var isResizerHovering = false
|
||
@State private var isResizerDragging = false
|
||
private let sidebarHandleWidth: CGFloat = 6
|
||
@State private var selectedTabIds: Set<UUID> = []
|
||
@State private var 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
|
||
|
||
private var sidebarView: some View {
|
||
VerticalTabsSidebar(
|
||
updateViewModel: updateViewModel,
|
||
selection: $sidebarSelectionState.selection,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
||
)
|
||
.frame(width: sidebarWidth)
|
||
.background(GeometryReader { proxy in
|
||
Color.clear
|
||
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
|
||
})
|
||
.overlay(alignment: .trailing) {
|
||
Color.clear
|
||
.frame(width: sidebarHandleWidth)
|
||
.contentShape(Rectangle())
|
||
.accessibilityIdentifier("SidebarResizer")
|
||
.onHover { hovering in
|
||
if hovering {
|
||
if !isResizerHovering {
|
||
NSCursor.resizeLeftRight.push()
|
||
isResizerHovering = true
|
||
}
|
||
} else if isResizerHovering {
|
||
if !isResizerDragging {
|
||
NSCursor.pop()
|
||
isResizerHovering = false
|
||
}
|
||
}
|
||
}
|
||
.onDisappear {
|
||
if isResizerHovering || isResizerDragging {
|
||
NSCursor.pop()
|
||
isResizerHovering = false
|
||
isResizerDragging = false
|
||
}
|
||
}
|
||
.gesture(
|
||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||
.onChanged { value in
|
||
if !isResizerDragging {
|
||
isResizerDragging = true
|
||
#if DEBUG
|
||
dlog("sidebar.resizeDragStart")
|
||
#endif
|
||
if !isResizerHovering {
|
||
NSCursor.resizeLeftRight.push()
|
||
isResizerHovering = true
|
||
}
|
||
}
|
||
let maxSidebarWidth = (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
|
||
let nextWidth = max(186, min(maxSidebarWidth, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
|
||
withTransaction(Transaction(animation: nil)) {
|
||
sidebarWidth = nextWidth
|
||
}
|
||
}
|
||
.onEnded { _ in
|
||
if isResizerDragging {
|
||
isResizerDragging = false
|
||
if !isResizerHovering {
|
||
NSCursor.pop()
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
|
||
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
|
||
@State private var titlebarPadding: CGFloat = 32
|
||
|
||
private var terminalContent: some View {
|
||
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
|
||
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
|
||
let selectedWorkspaceId = tabManager.selectedTabId
|
||
let retiringWorkspaceId = self.retiringWorkspaceId
|
||
|
||
return ZStack {
|
||
ZStack {
|
||
ForEach(mountedWorkspaces) { tab in
|
||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||
WorkspaceContentView(
|
||
workspace: tab,
|
||
isWorkspaceVisible: isVisible,
|
||
isWorkspaceInputActive: isInputActive,
|
||
workspacePortalPriority: portalPriority
|
||
)
|
||
.opacity(isVisible ? 1 : 0)
|
||
.allowsHitTesting(isSelectedWorkspace)
|
||
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
||
}
|
||
}
|
||
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
||
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
|
||
|
||
NotificationsPage(selection: $sidebarSelectionState.selection)
|
||
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
|
||
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
|
||
}
|
||
.padding(.top, titlebarPadding)
|
||
.overlay(alignment: .top) {
|
||
// Titlebar overlay is only over terminal content, not the sidebar.
|
||
customTitlebar
|
||
}
|
||
}
|
||
|
||
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
||
|
||
// Background glass settings
|
||
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
||
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
|
||
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
||
@AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0
|
||
|
||
@State private var titlebarLeadingInset: CGFloat = 12
|
||
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
|
||
private var fakeTitlebarBackground: Color {
|
||
_ = titlebarThemeGeneration
|
||
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
|
||
let configuredOpacity = CGFloat(max(0, min(1, GhosttyApp.shared.defaultBackgroundOpacity)))
|
||
let minimumChromeOpacity: CGFloat = ghosttyBackground.isLightColor ? 0.90 : 0.84
|
||
let chromeOpacity = max(minimumChromeOpacity, configuredOpacity)
|
||
return Color(nsColor: ghosttyBackground.withAlphaComponent(chromeOpacity))
|
||
}
|
||
private var fakeTitlebarTextColor: Color {
|
||
_ = titlebarThemeGeneration
|
||
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
|
||
return ghosttyBackground.isLightColor
|
||
? Color.black.opacity(0.78)
|
||
: Color.white.opacity(0.82)
|
||
}
|
||
private var fakeTitlebarSeparatorColor: Color {
|
||
_ = titlebarThemeGeneration
|
||
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
|
||
return ghosttyBackground.isLightColor
|
||
? Color.black.opacity(0.18)
|
||
: Color.white.opacity(0.22)
|
||
}
|
||
|
||
private var fullscreenControls: some View {
|
||
TitlebarControlsView(
|
||
notificationStore: TerminalNotificationStore.shared,
|
||
viewModel: fullscreenControlsViewModel,
|
||
onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() },
|
||
onToggleNotifications: { [fullscreenControlsViewModel] in
|
||
AppDelegate.shared?.toggleNotificationsPopover(
|
||
animated: true,
|
||
anchorView: fullscreenControlsViewModel.notificationsAnchorView
|
||
)
|
||
},
|
||
onNewTab: { tabManager.addTab() }
|
||
)
|
||
}
|
||
|
||
private var customTitlebar: some View {
|
||
ZStack {
|
||
// Enable window dragging from the titlebar strip without making the entire content
|
||
// view draggable (which breaks drag gestures like tab reordering).
|
||
WindowDragHandleView()
|
||
|
||
TitlebarLeadingInsetReader(inset: $titlebarLeadingInset)
|
||
|
||
HStack(spacing: 8) {
|
||
if isFullScreen && !sidebarState.isVisible {
|
||
fullscreenControls
|
||
}
|
||
|
||
// Draggable folder icon + focused command name
|
||
if let directory = focusedDirectory {
|
||
DraggableFolderIcon(directory: directory)
|
||
}
|
||
|
||
Text(titlebarText)
|
||
.font(.system(size: 13, weight: .bold))
|
||
.foregroundColor(fakeTitlebarTextColor)
|
||
.lineLimit(1)
|
||
|
||
Spacer()
|
||
|
||
}
|
||
.frame(height: 28)
|
||
.padding(.top, 2)
|
||
.padding(.leading, (isFullScreen && !sidebarState.isVisible) ? 8 : (sidebarState.isVisible ? 12 : titlebarLeadingInset + CGFloat(debugTitlebarLeadingExtra)))
|
||
.padding(.trailing, 8)
|
||
}
|
||
.frame(height: titlebarPadding)
|
||
.frame(maxWidth: .infinity)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(count: 2) {
|
||
NSApp.keyWindow?.zoom(nil)
|
||
}
|
||
.background(fakeTitlebarBackground)
|
||
.overlay(alignment: .bottom) {
|
||
Rectangle()
|
||
.fill(fakeTitlebarSeparatorColor)
|
||
.frame(height: 1)
|
||
}
|
||
}
|
||
|
||
private func updateTitlebarText() {
|
||
guard let selectedId = tabManager.selectedTabId,
|
||
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
|
||
titlebarText = ""
|
||
return
|
||
}
|
||
let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
titlebarText = title
|
||
}
|
||
|
||
private var focusedDirectory: String? {
|
||
guard let selectedId = tabManager.selectedTabId,
|
||
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
|
||
return nil
|
||
}
|
||
// Use focused panel's directory if available
|
||
if let focusedPanelId = tab.focusedPanelId,
|
||
let panelDir = tab.panelDirectories[focusedPanelId] {
|
||
let trimmed = panelDir.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !trimmed.isEmpty {
|
||
return trimmed
|
||
}
|
||
}
|
||
let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return dir.isEmpty ? nil : dir
|
||
}
|
||
|
||
var body: some View {
|
||
let useOverlay = sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue
|
||
|
||
Group {
|
||
if useOverlay {
|
||
// Overlay mode: terminal extends full width, sidebar on top
|
||
// This allows withinWindow blur to see the terminal content
|
||
ZStack(alignment: .leading) {
|
||
terminalContent
|
||
.padding(.leading, sidebarState.isVisible ? sidebarWidth : 0)
|
||
if sidebarState.isVisible {
|
||
sidebarView
|
||
}
|
||
}
|
||
} else {
|
||
// Standard HStack mode for behindWindow blur
|
||
HStack(spacing: 0) {
|
||
if sidebarState.isVisible {
|
||
sidebarView
|
||
}
|
||
terminalContent
|
||
}
|
||
}
|
||
}
|
||
.overlay(alignment: .topLeading) {
|
||
if isFullScreen && sidebarState.isVisible {
|
||
fullscreenControls
|
||
.padding(.leading, 10)
|
||
.padding(.top, 4)
|
||
}
|
||
}
|
||
.frame(minWidth: 800, minHeight: 600)
|
||
.background(Color.clear)
|
||
.onAppear {
|
||
tabManager.applyWindowBackgroundForSelectedTab()
|
||
reconcileMountedWorkspaceIds()
|
||
previousSelectedWorkspaceId = tabManager.selectedTabId
|
||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
}
|
||
updateTitlebarText()
|
||
}
|
||
.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()
|
||
}
|
||
.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()
|
||
}
|
||
.onChange(of: retiringWorkspaceId) { _ in
|
||
reconcileMountedWorkspaceIds()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
|
||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||
tabId == tabManager.selectedTabId else { return }
|
||
updateTitlebarText()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in
|
||
sidebarSelectionState.selection = .tabs
|
||
updateTitlebarText()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
|
||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||
tabId == tabManager.selectedTabId else { return }
|
||
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
|
||
updateTitlebarText()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
|
||
titlebarThemeGeneration &+= 1
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
|
||
titlebarThemeGeneration &+= 1
|
||
}
|
||
.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")
|
||
}
|
||
.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
|
||
}
|
||
}
|
||
}
|
||
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
|
||
sidebarMinX = frame.minX
|
||
}
|
||
.onChange(of: bgGlassTintHex) { _ in
|
||
updateWindowGlassTint()
|
||
}
|
||
.onChange(of: bgGlassTintOpacity) { _ in
|
||
updateWindowGlassTint()
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { notification in
|
||
guard let window = notification.object as? NSWindow,
|
||
window === observedWindow else { return }
|
||
isFullScreen = true
|
||
setTitlebarControlsHidden(true, in: window)
|
||
AppDelegate.shared?.fullscreenControlsViewModel = fullscreenControlsViewModel
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { notification in
|
||
guard let window = notification.object as? NSWindow,
|
||
window === observedWindow else { return }
|
||
isFullScreen = false
|
||
setTitlebarControlsHidden(false, in: window)
|
||
AppDelegate.shared?.fullscreenControlsViewModel = nil
|
||
}
|
||
.ignoresSafeArea()
|
||
.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
|
||
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
|
||
window.titlebarAppearsTransparent = true
|
||
// Do not make the entire background draggable; it interferes with drag gestures
|
||
// like sidebar tab reordering in multi-window mode.
|
||
window.isMovableByWindowBackground = false
|
||
window.styleMask.insert(.fullSizeContentView)
|
||
|
||
// Track this window for fullscreen notifications
|
||
if observedWindow !== window {
|
||
DispatchQueue.main.async {
|
||
observedWindow = window
|
||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||
}
|
||
}
|
||
|
||
// Keep content below the titlebar so drags on Bonsplit's tab bar don't
|
||
// get interpreted as window drags.
|
||
let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height
|
||
let nextPadding = max(28, min(72, computedTitlebarHeight))
|
||
if abs(titlebarPadding - nextPadding) > 0.5 {
|
||
DispatchQueue.main.async {
|
||
titlebarPadding = nextPadding
|
||
}
|
||
}
|
||
#if DEBUG
|
||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {
|
||
UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)")
|
||
}
|
||
#endif
|
||
// Background glass: skip on macOS 26+ where NSGlassEffectView can cause blank
|
||
// or incorrectly tinted SwiftUI content. Keep native window rendering there so
|
||
// Ghostty theme colors remain authoritative.
|
||
if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue
|
||
&& bgGlassEnabled
|
||
&& !WindowGlassEffect.isAvailable {
|
||
window.isOpaque = false
|
||
window.backgroundColor = .clear
|
||
// Configure contentView and all subviews for transparency
|
||
if let contentView = window.contentView {
|
||
contentView.wantsLayer = true
|
||
contentView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
contentView.layer?.isOpaque = false
|
||
// Make SwiftUI hosting view transparent
|
||
for subview in contentView.subviews {
|
||
subview.wantsLayer = true
|
||
subview.layer?.backgroundColor = NSColor.clear.cgColor
|
||
subview.layer?.isOpaque = false
|
||
}
|
||
}
|
||
// Apply liquid glass effect to the window with tint from settings
|
||
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
|
||
WindowGlassEffect.apply(to: window, tintColor: tintColor)
|
||
}
|
||
AppDelegate.shared?.attachUpdateAccessory(to: window)
|
||
AppDelegate.shared?.applyWindowDecorations(to: window)
|
||
AppDelegate.shared?.registerMainWindow(
|
||
window,
|
||
windowId: windowId,
|
||
tabManager: tabManager,
|
||
sidebarState: sidebarState,
|
||
sidebarSelectionState: sidebarSelectionState
|
||
)
|
||
installFileDropOverlay(on: window, tabManager: tabManager)
|
||
})
|
||
}
|
||
|
||
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
|
||
let currentTabs = tabs ?? tabManager.tabs
|
||
let orderedTabIds = currentTabs.map { $0.id }
|
||
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
||
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
|
||
let isCycleHot = tabManager.isWorkspaceCycleHot
|
||
let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty
|
||
let baseMaxMounted = shouldKeepHandoffPair
|
||
? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||
: WorkspaceMountPolicy.maxMountedWorkspaces
|
||
let selectedCount = effectiveSelectedId == nil ? 0 : 1
|
||
let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count)
|
||
let previousMountedIds = mountedWorkspaceIds
|
||
mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||
current: mountedWorkspaceIds,
|
||
selected: effectiveSelectedId,
|
||
pinnedIds: pinnedIds,
|
||
orderedTabIds: orderedTabIds,
|
||
isCycleHot: isCycleHot,
|
||
maxMounted: maxMounted
|
||
)
|
||
#if DEBUG
|
||
if mountedWorkspaceIds != previousMountedIds {
|
||
let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) }
|
||
let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) }
|
||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||
dlog(
|
||
"ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " +
|
||
"selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
|
||
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " +
|
||
"added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))"
|
||
)
|
||
} else {
|
||
dlog(
|
||
"ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
|
||
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))"
|
||
)
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
private func addTab() {
|
||
tabManager.addTab()
|
||
sidebarSelectionState.selection = .tabs
|
||
}
|
||
|
||
private func updateWindowGlassTint() {
|
||
// Find this view's main window by identifier (keyWindow might be a debug panel/settings).
|
||
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return }
|
||
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
|
||
WindowGlassEffect.updateTint(to: window, color: tintColor)
|
||
}
|
||
|
||
private func setTitlebarControlsHidden(_ hidden: Bool, in window: NSWindow) {
|
||
let controlsId = NSUserInterfaceItemIdentifier("cmux.titlebarControls")
|
||
for accessory in window.titlebarAccessoryViewControllers {
|
||
if accessory.view.identifier == controlsId {
|
||
accessory.isHidden = hidden
|
||
accessory.view.alphaValue = hidden ? 0 : 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) {
|
||
let oldSelectedId = previousSelectedWorkspaceId
|
||
previousSelectedWorkspaceId = newSelectedId
|
||
|
||
guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else {
|
||
tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff")
|
||
retiringWorkspaceId = nil
|
||
workspaceHandoffFallbackTask?.cancel()
|
||
workspaceHandoffFallbackTask = nil
|
||
return
|
||
}
|
||
|
||
workspaceHandoffGeneration &+= 1
|
||
let generation = workspaceHandoffGeneration
|
||
retiringWorkspaceId = oldSelectedId
|
||
workspaceHandoffFallbackTask?.cancel()
|
||
|
||
#if DEBUG
|
||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||
dlog(
|
||
"ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " +
|
||
"new=\(debugShortWorkspaceId(newSelectedId))"
|
||
)
|
||
} else {
|
||
dlog(
|
||
"ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))"
|
||
)
|
||
}
|
||
#endif
|
||
|
||
workspaceHandoffFallbackTask = Task { [generation] in
|
||
do {
|
||
try await Task.sleep(nanoseconds: 150_000_000)
|
||
} catch {
|
||
return
|
||
}
|
||
await MainActor.run {
|
||
guard workspaceHandoffGeneration == generation else { return }
|
||
completeWorkspaceHandoff(reason: "timeout")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) {
|
||
guard focusedTabId == tabManager.selectedTabId else { return }
|
||
guard retiringWorkspaceId != nil else { return }
|
||
completeWorkspaceHandoff(reason: reason)
|
||
}
|
||
|
||
private func completeWorkspaceHandoff(reason: String) {
|
||
workspaceHandoffFallbackTask?.cancel()
|
||
workspaceHandoffFallbackTask = nil
|
||
let retiring = retiringWorkspaceId
|
||
|
||
// Hide terminal portal views for the retiring workspace BEFORE clearing
|
||
// retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts
|
||
// the workspace — but dismantleNSView intentionally doesn't hide portal views
|
||
// (to avoid blackouts during transient bonsplit dismantles). Hiding here
|
||
// prevents stale portal-hosted terminals from covering browser panes.
|
||
if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) {
|
||
workspace.hideAllTerminalPortalViews()
|
||
}
|
||
|
||
retiringWorkspaceId = nil
|
||
tabManager.completePendingWorkspaceUnfocus(reason: reason)
|
||
#if DEBUG
|
||
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||
dlog(
|
||
"ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))"
|
||
)
|
||
} else {
|
||
dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))")
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#if DEBUG
|
||
private func debugShortWorkspaceId(_ id: UUID?) -> String {
|
||
guard let id else { return "nil" }
|
||
return String(id.uuidString.prefix(5))
|
||
}
|
||
|
||
private func debugShortWorkspaceIds(_ ids: [UUID]) -> String {
|
||
if ids.isEmpty { return "[]" }
|
||
return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]"
|
||
}
|
||
|
||
private func debugMsText(_ ms: Double) -> String {
|
||
String(format: "%.2fms", ms)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
struct VerticalTabsSidebar: View {
|
||
@ObservedObject var updateViewModel: UpdateViewModel
|
||
@EnvironmentObject var tabManager: TabManager
|
||
@Binding var selection: SidebarSelection
|
||
@Binding var selectedTabIds: Set<UUID>
|
||
@Binding var lastSidebarSelectionIndex: Int?
|
||
@StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor()
|
||
@StateObject private var dragAutoScrollController = SidebarDragAutoScrollController()
|
||
@State private var draggedTabId: UUID?
|
||
@State private var dropIndicator: SidebarDropIndicator?
|
||
|
||
/// Space at top of sidebar for traffic light buttons
|
||
private let trafficLightPadding: CGFloat = 28
|
||
private let tabRowSpacing: CGFloat = 2
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
GeometryReader { proxy in
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
// Space for traffic lights / fullscreen controls
|
||
Spacer()
|
||
.frame(height: trafficLightPadding)
|
||
|
||
LazyVStack(spacing: tabRowSpacing) {
|
||
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
||
TabItemView(
|
||
tab: tab,
|
||
index: index,
|
||
rowSpacing: tabRowSpacing,
|
||
selection: $selection,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||
showsCommandShortcutHints: commandKeyMonitor.isCommandPressed,
|
||
dragAutoScrollController: dragAutoScrollController,
|
||
draggedTabId: $draggedTabId,
|
||
dropIndicator: $dropIndicator
|
||
)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
|
||
SidebarEmptyArea(
|
||
rowSpacing: tabRowSpacing,
|
||
selection: $selection,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||
dragAutoScrollController: dragAutoScrollController,
|
||
draggedTabId: $draggedTabId,
|
||
dropIndicator: $dropIndicator
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.frame(minHeight: proxy.size.height, alignment: .top)
|
||
}
|
||
.background(
|
||
SidebarScrollViewResolver { scrollView in
|
||
dragAutoScrollController.attach(scrollView: scrollView)
|
||
}
|
||
.frame(width: 0, height: 0)
|
||
)
|
||
.overlay(alignment: .top) {
|
||
SidebarTopScrim(height: trafficLightPadding + 20)
|
||
.allowsHitTesting(false)
|
||
}
|
||
.overlay(alignment: .top) {
|
||
// Double-click the sidebar title-bar area to zoom the
|
||
// window, matching the panel top-bar behaviour.
|
||
DoubleClickZoomView()
|
||
.frame(height: trafficLightPadding)
|
||
}
|
||
.background(Color.clear)
|
||
.modifier(ClearScrollBackground())
|
||
}
|
||
#if DEBUG
|
||
SidebarDevFooter(updateViewModel: updateViewModel)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
#else
|
||
UpdatePill(model: updateViewModel)
|
||
.padding(.horizontal, 10)
|
||
.padding(.bottom, 10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
#endif
|
||
}
|
||
.accessibilityIdentifier("Sidebar")
|
||
.ignoresSafeArea()
|
||
.background(SidebarBackdrop().ignoresSafeArea())
|
||
.onAppear {
|
||
commandKeyMonitor.start()
|
||
draggedTabId = nil
|
||
dropIndicator = nil
|
||
}
|
||
.onDisappear {
|
||
commandKeyMonitor.stop()
|
||
dragAutoScrollController.stop()
|
||
draggedTabId = nil
|
||
dropIndicator = nil
|
||
}
|
||
.onChange(of: draggedTabId) { newDraggedTabId in
|
||
guard newDraggedTabId == nil else { return }
|
||
dragAutoScrollController.stop()
|
||
dropIndicator = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarCommandHintPolicy {
|
||
static let intentionalHoldDelay: TimeInterval = 0.30
|
||
|
||
static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool {
|
||
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
|
||
}
|
||
}
|
||
|
||
enum ShortcutHintDebugSettings {
|
||
static let sidebarHintXKey = "shortcutHintSidebarXOffset"
|
||
static let sidebarHintYKey = "shortcutHintSidebarYOffset"
|
||
static let titlebarHintXKey = "shortcutHintTitlebarXOffset"
|
||
static let titlebarHintYKey = "shortcutHintTitlebarYOffset"
|
||
static let paneHintXKey = "shortcutHintPaneTabXOffset"
|
||
static let paneHintYKey = "shortcutHintPaneTabYOffset"
|
||
static let alwaysShowHintsKey = "shortcutHintAlwaysShow"
|
||
|
||
static let defaultSidebarHintX = 0.0
|
||
static let defaultSidebarHintY = 0.0
|
||
static let defaultTitlebarHintX = 4.0
|
||
static let defaultTitlebarHintY = 0.0
|
||
static let defaultPaneHintX = 0.0
|
||
static let defaultPaneHintY = 0.0
|
||
static let defaultAlwaysShowHints = false
|
||
|
||
static let offsetRange: ClosedRange<Double> = -20...20
|
||
|
||
static func clamped(_ value: Double) -> Double {
|
||
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class SidebarCommandKeyMonitor: ObservableObject {
|
||
@Published private(set) var isCommandPressed = false
|
||
|
||
private var flagsMonitor: Any?
|
||
private var keyDownMonitor: Any?
|
||
private var resignObserver: NSObjectProtocol?
|
||
private var pendingShowWorkItem: DispatchWorkItem?
|
||
|
||
func start() {
|
||
guard flagsMonitor == nil else {
|
||
update(from: NSEvent.modifierFlags)
|
||
return
|
||
}
|
||
|
||
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||
self?.update(from: event.modifierFlags)
|
||
return event
|
||
}
|
||
|
||
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||
self?.cancelPendingHintShow(resetVisible: true)
|
||
return event
|
||
}
|
||
|
||
resignObserver = NotificationCenter.default.addObserver(
|
||
forName: NSApplication.didResignActiveNotification,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.cancelPendingHintShow(resetVisible: true)
|
||
}
|
||
}
|
||
|
||
update(from: NSEvent.modifierFlags)
|
||
}
|
||
|
||
func stop() {
|
||
if let flagsMonitor {
|
||
NSEvent.removeMonitor(flagsMonitor)
|
||
self.flagsMonitor = nil
|
||
}
|
||
if let keyDownMonitor {
|
||
NSEvent.removeMonitor(keyDownMonitor)
|
||
self.keyDownMonitor = nil
|
||
}
|
||
if let resignObserver {
|
||
NotificationCenter.default.removeObserver(resignObserver)
|
||
self.resignObserver = nil
|
||
}
|
||
cancelPendingHintShow(resetVisible: true)
|
||
}
|
||
|
||
private func update(from modifierFlags: NSEvent.ModifierFlags) {
|
||
guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else {
|
||
cancelPendingHintShow(resetVisible: true)
|
||
return
|
||
}
|
||
|
||
queueHintShow()
|
||
}
|
||
|
||
private func queueHintShow() {
|
||
guard !isCommandPressed else { return }
|
||
guard pendingShowWorkItem == nil else { return }
|
||
|
||
let workItem = DispatchWorkItem { [weak self] in
|
||
guard let self else { return }
|
||
self.pendingShowWorkItem = nil
|
||
guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return }
|
||
self.isCommandPressed = true
|
||
}
|
||
|
||
pendingShowWorkItem = workItem
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
|
||
}
|
||
|
||
private func cancelPendingHintShow(resetVisible: Bool) {
|
||
pendingShowWorkItem?.cancel()
|
||
pendingShowWorkItem = nil
|
||
if resetVisible {
|
||
isCommandPressed = false
|
||
}
|
||
}
|
||
}
|
||
|
||
#if DEBUG
|
||
private struct SidebarDevFooter: View {
|
||
@ObservedObject var updateViewModel: UpdateViewModel
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
UpdatePill(model: updateViewModel)
|
||
Text("THIS IS A DEV BUILD")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(.red)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.bottom, 10)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
private struct SidebarTopScrim: View {
|
||
let height: CGFloat
|
||
|
||
var body: some View {
|
||
SidebarTopBlurEffect()
|
||
.frame(height: height)
|
||
.mask(
|
||
LinearGradient(
|
||
colors: [
|
||
Color.black.opacity(0.95),
|
||
Color.black.opacity(0.75),
|
||
Color.black.opacity(0.35),
|
||
Color.clear
|
||
],
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct SidebarTopBlurEffect: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||
let view = NSVisualEffectView()
|
||
view.blendingMode = .withinWindow
|
||
view.material = .underWindowBackground
|
||
view.state = .active
|
||
view.isEmphasized = false
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
|
||
}
|
||
|
||
private struct SidebarFramePreferenceKey: PreferenceKey {
|
||
static var defaultValue: CGRect = .zero
|
||
|
||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||
value = nextValue()
|
||
}
|
||
}
|
||
|
||
private struct SidebarScrollViewResolver: NSViewRepresentable {
|
||
let onResolve: (NSScrollView?) -> Void
|
||
|
||
func makeNSView(context: Context) -> SidebarScrollViewResolverView {
|
||
let view = SidebarScrollViewResolverView()
|
||
view.onResolve = onResolve
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) {
|
||
nsView.onResolve = onResolve
|
||
nsView.resolveScrollView()
|
||
}
|
||
}
|
||
|
||
private final class SidebarScrollViewResolverView: NSView {
|
||
var onResolve: ((NSScrollView?) -> Void)?
|
||
|
||
override func viewDidMoveToSuperview() {
|
||
super.viewDidMoveToSuperview()
|
||
resolveScrollView()
|
||
}
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
resolveScrollView()
|
||
}
|
||
|
||
func resolveScrollView() {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self else { return }
|
||
onResolve?(self.enclosingScrollView)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SidebarEmptyArea: View {
|
||
@EnvironmentObject var tabManager: TabManager
|
||
let rowSpacing: CGFloat
|
||
@Binding var selection: SidebarSelection
|
||
@Binding var selectedTabIds: Set<UUID>
|
||
@Binding var lastSidebarSelectionIndex: Int?
|
||
let dragAutoScrollController: SidebarDragAutoScrollController
|
||
@Binding var draggedTabId: UUID?
|
||
@Binding var dropIndicator: SidebarDropIndicator?
|
||
|
||
var body: some View {
|
||
Color.clear
|
||
.contentShape(Rectangle())
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.onTapGesture(count: 2) {
|
||
tabManager.addTab()
|
||
if let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
}
|
||
selection = .tabs
|
||
}
|
||
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
|
||
targetTabId: nil,
|
||
tabManager: tabManager,
|
||
draggedTabId: $draggedTabId,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||
targetRowHeight: nil,
|
||
dragAutoScrollController: dragAutoScrollController,
|
||
dropIndicator: $dropIndicator
|
||
))
|
||
.overlay(alignment: .top) {
|
||
if shouldShowTopDropIndicator {
|
||
Rectangle()
|
||
.fill(Color.accentColor)
|
||
.frame(height: 2)
|
||
.padding(.horizontal, 8)
|
||
.offset(y: -(rowSpacing / 2))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var shouldShowTopDropIndicator: Bool {
|
||
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
|
||
if indicator.tabId == nil {
|
||
return true
|
||
}
|
||
guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false }
|
||
return indicator.tabId == lastTabId
|
||
}
|
||
}
|
||
|
||
private struct TabItemView: View {
|
||
@EnvironmentObject var tabManager: TabManager
|
||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||
@ObservedObject var tab: Tab
|
||
let index: Int
|
||
let rowSpacing: CGFloat
|
||
@Binding var selection: SidebarSelection
|
||
@Binding var selectedTabIds: Set<UUID>
|
||
@Binding var lastSidebarSelectionIndex: Int?
|
||
let showsCommandShortcutHints: Bool
|
||
let dragAutoScrollController: SidebarDragAutoScrollController
|
||
@Binding var draggedTabId: UUID?
|
||
@Binding var dropIndicator: SidebarDropIndicator?
|
||
@State private var isHovering = false
|
||
@State private var rowHeight: CGFloat = 1
|
||
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
||
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
|
||
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
|
||
|
||
var isActive: Bool {
|
||
tabManager.selectedTabId == tab.id
|
||
}
|
||
|
||
var isMultiSelected: Bool {
|
||
selectedTabIds.contains(tab.id)
|
||
}
|
||
|
||
private var isBeingDragged: Bool {
|
||
draggedTabId == tab.id
|
||
}
|
||
|
||
private var workspaceShortcutDigit: Int? {
|
||
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
|
||
}
|
||
|
||
private var showCloseButton: Bool {
|
||
isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints)
|
||
}
|
||
|
||
private var workspaceShortcutLabel: String? {
|
||
guard let workspaceShortcutDigit else { return nil }
|
||
return "⌘\(workspaceShortcutDigit)"
|
||
}
|
||
|
||
private var showsWorkspaceShortcutHint: Bool {
|
||
(showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
|
||
}
|
||
|
||
private var workspaceHintSlotWidth: CGFloat {
|
||
guard let label = workspaceShortcutLabel else { return 28 }
|
||
let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + 2
|
||
return max(28, workspaceHintWidth(for: label) + positiveDebugInset)
|
||
}
|
||
|
||
private func workspaceHintWidth(for label: String) -> CGFloat {
|
||
let font = NSFont.systemFont(ofSize: 10, weight: .semibold)
|
||
let textWidth = (label as NSString).size(withAttributes: [.font: font]).width
|
||
return ceil(textWidth) + 12
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 8) {
|
||
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
|
||
if unreadCount > 0 {
|
||
ZStack {
|
||
Circle()
|
||
.fill(isActive ? Color.white.opacity(0.25) : Color.accentColor)
|
||
Text("\(unreadCount)")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
.frame(width: 16, height: 16)
|
||
}
|
||
|
||
if tab.isPinned {
|
||
Image(systemName: "pin.fill")
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
||
}
|
||
|
||
Text(tab.title)
|
||
.font(.system(size: 12.5, weight: .semibold))
|
||
.foregroundColor(isActive ? .white : .primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
|
||
Spacer()
|
||
|
||
ZStack(alignment: .trailing) {
|
||
Button(action: {
|
||
#if DEBUG
|
||
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button")
|
||
#endif
|
||
tabManager.closeWorkspaceWithConfirmation(tab)
|
||
}) {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 9, weight: .medium))
|
||
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))")
|
||
.frame(width: 16, height: 16, alignment: .center)
|
||
.opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0)
|
||
.allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint)
|
||
|
||
if showsWorkspaceShortcutHint, let workspaceShortcutLabel {
|
||
Text(workspaceShortcutLabel)
|
||
.lineLimit(1)
|
||
.fixedSize(horizontal: true, vertical: false)
|
||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||
.monospacedDigit()
|
||
.foregroundColor(isActive ? .white : .primary)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 2)
|
||
.background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9))
|
||
.offset(
|
||
x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset),
|
||
y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)
|
||
)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
|
||
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
|
||
}
|
||
|
||
if let subtitle = latestNotificationText {
|
||
Text(subtitle)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
||
.lineLimit(2)
|
||
.truncationMode(.tail)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
|
||
if sidebarShowStatusPills, !tab.statusEntries.isEmpty {
|
||
SidebarStatusPillsRow(
|
||
entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
|
||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
||
return lhs.key < rhs.key
|
||
}),
|
||
isActive: isActive,
|
||
onFocus: { updateSelection() }
|
||
)
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
|
||
// Latest log entry
|
||
if sidebarShowLog, let latestLog = tab.logEntries.last {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: logLevelIcon(latestLog.level))
|
||
.font(.system(size: 8))
|
||
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive))
|
||
Text(latestLog.message)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
|
||
// Progress bar
|
||
if sidebarShowProgress, let progress = tab.progress {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2))
|
||
Capsule()
|
||
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor)
|
||
.frame(width: max(0, geo.size.width * CGFloat(progress.value)))
|
||
}
|
||
}
|
||
.frame(height: 3)
|
||
|
||
if let label = progress.label {
|
||
Text(label)
|
||
.font(.system(size: 9))
|
||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||
}
|
||
|
||
// Branch + directory row
|
||
if let dirRow = branchDirectoryRow {
|
||
HStack(spacing: 3) {
|
||
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
|
||
Image(systemName: "arrow.triangle.branch")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
||
}
|
||
Text(dirRow)
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
|
||
// Ports row
|
||
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
||
Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", "))
|
||
.font(.system(size: 10, design: .monospaced))
|
||
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
|
||
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.fill(backgroundColor)
|
||
)
|
||
.padding(.horizontal, 6)
|
||
.background {
|
||
GeometryReader { proxy in
|
||
Color.clear
|
||
.onAppear {
|
||
rowHeight = max(proxy.size.height, 1)
|
||
}
|
||
.onChange(of: proxy.size.height) { newHeight in
|
||
rowHeight = max(newHeight, 1)
|
||
}
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
.opacity(isBeingDragged ? 0.6 : 1)
|
||
.overlay {
|
||
MiddleClickCapture {
|
||
#if DEBUG
|
||
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick")
|
||
#endif
|
||
tabManager.closeWorkspaceWithConfirmation(tab)
|
||
}
|
||
}
|
||
.overlay(alignment: .top) {
|
||
if showsCenteredTopDropIndicator {
|
||
Rectangle()
|
||
.fill(Color.accentColor)
|
||
.frame(height: 2)
|
||
.padding(.horizontal, 8)
|
||
.offset(y: index == 0 ? 0 : -(rowSpacing / 2))
|
||
}
|
||
}
|
||
.onDrag {
|
||
#if DEBUG
|
||
dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))")
|
||
#endif
|
||
draggedTabId = tab.id
|
||
dropIndicator = nil
|
||
return SidebarTabDragPayload.provider(for: tab.id)
|
||
}
|
||
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
|
||
targetTabId: tab.id,
|
||
tabManager: tabManager,
|
||
draggedTabId: $draggedTabId,
|
||
selectedTabIds: $selectedTabIds,
|
||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||
targetRowHeight: rowHeight,
|
||
dragAutoScrollController: dragAutoScrollController,
|
||
dropIndicator: $dropIndicator
|
||
))
|
||
.onTapGesture {
|
||
updateSelection()
|
||
}
|
||
.onHover { hovering in
|
||
isHovering = hovering
|
||
}
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(Text(accessibilityTitle))
|
||
.accessibilityHint(Text("Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions."))
|
||
.accessibilityAction(named: Text("Move Up")) {
|
||
moveBy(-1)
|
||
}
|
||
.accessibilityAction(named: Text("Move Down")) {
|
||
moveBy(1)
|
||
}
|
||
.contextMenu {
|
||
let targetIds = contextTargetIds()
|
||
let shouldPin = !tab.isPinned
|
||
let pinLabel = targetIds.count > 1
|
||
? (shouldPin ? "Pin Tabs" : "Unpin Tabs")
|
||
: (shouldPin ? "Pin Tab" : "Unpin Tab")
|
||
Button(pinLabel) {
|
||
for id in targetIds {
|
||
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
||
tabManager.setPinned(tab, pinned: shouldPin)
|
||
}
|
||
}
|
||
syncSelectionAfterMutation()
|
||
}
|
||
|
||
Button("Rename Tab…") {
|
||
promptRename()
|
||
}
|
||
|
||
if tab.hasCustomTitle {
|
||
Button("Remove Custom Name") {
|
||
tabManager.clearCustomTitle(tabId: tab.id)
|
||
}
|
||
}
|
||
|
||
Divider()
|
||
|
||
Button("Move Up") {
|
||
moveBy(-1)
|
||
}
|
||
.disabled(index == 0)
|
||
|
||
Button("Move Down") {
|
||
moveBy(1)
|
||
}
|
||
.disabled(index >= tabManager.tabs.count - 1)
|
||
|
||
Divider()
|
||
|
||
Button("Close Tabs") {
|
||
closeTabs(targetIds, allowPinned: true)
|
||
}
|
||
.disabled(targetIds.isEmpty)
|
||
|
||
Button("Close Others") {
|
||
closeOtherTabs(targetIds)
|
||
}
|
||
.disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count)
|
||
|
||
Button("Close Tabs Below") {
|
||
closeTabsBelow(tabId: tab.id)
|
||
}
|
||
.disabled(index >= tabManager.tabs.count - 1)
|
||
|
||
Button("Close Tabs Above") {
|
||
closeTabsAbove(tabId: tab.id)
|
||
}
|
||
.disabled(index == 0)
|
||
|
||
Divider()
|
||
|
||
Button("Move to Top") {
|
||
tabManager.moveTabsToTop(Set(targetIds))
|
||
syncSelectionAfterMutation()
|
||
}
|
||
.disabled(targetIds.isEmpty)
|
||
|
||
Divider()
|
||
|
||
Button("Mark as Read") {
|
||
markTabsRead(targetIds)
|
||
}
|
||
.disabled(!hasUnreadNotifications(in: targetIds))
|
||
|
||
Button("Mark as Unread") {
|
||
markTabsUnread(targetIds)
|
||
}
|
||
.disabled(!hasReadNotifications(in: targetIds))
|
||
}
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
if isActive {
|
||
return Color.accentColor
|
||
}
|
||
if isMultiSelected {
|
||
return Color.accentColor.opacity(0.25)
|
||
}
|
||
return Color.clear
|
||
}
|
||
|
||
private var showsCenteredTopDropIndicator: Bool {
|
||
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
|
||
if indicator.tabId == tab.id && indicator.edge == .top {
|
||
return true
|
||
}
|
||
|
||
guard indicator.edge == .bottom,
|
||
let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }),
|
||
currentIndex > 0
|
||
else {
|
||
return false
|
||
}
|
||
return tabManager.tabs[currentIndex - 1].id == indicator.tabId
|
||
}
|
||
|
||
private var accessibilityTitle: String {
|
||
"\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)"
|
||
}
|
||
|
||
private func moveBy(_ delta: Int) {
|
||
let targetIndex = index + delta
|
||
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
|
||
guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return }
|
||
selectedTabIds = [tab.id]
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
|
||
tabManager.selectTab(tab)
|
||
selection = .tabs
|
||
}
|
||
|
||
private func updateSelection() {
|
||
#if DEBUG
|
||
let mods = NSEvent.modifierFlags
|
||
var modStr = ""
|
||
if mods.contains(.command) { modStr += "cmd " }
|
||
if mods.contains(.shift) { modStr += "shift " }
|
||
if mods.contains(.option) { modStr += "opt " }
|
||
if mods.contains(.control) { modStr += "ctrl " }
|
||
dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))")
|
||
#endif
|
||
let modifiers = NSEvent.modifierFlags
|
||
let isCommand = modifiers.contains(.command)
|
||
let isShift = modifiers.contains(.shift)
|
||
|
||
if isShift, let lastIndex = lastSidebarSelectionIndex {
|
||
let lower = min(lastIndex, index)
|
||
let upper = max(lastIndex, index)
|
||
let rangeIds = tabManager.tabs[lower...upper].map { $0.id }
|
||
if isCommand {
|
||
selectedTabIds.formUnion(rangeIds)
|
||
} else {
|
||
selectedTabIds = Set(rangeIds)
|
||
}
|
||
} else if isCommand {
|
||
if selectedTabIds.contains(tab.id) {
|
||
selectedTabIds.remove(tab.id)
|
||
} else {
|
||
selectedTabIds.insert(tab.id)
|
||
}
|
||
} else {
|
||
selectedTabIds = [tab.id]
|
||
}
|
||
|
||
lastSidebarSelectionIndex = index
|
||
tabManager.selectTab(tab)
|
||
selection = .tabs
|
||
}
|
||
|
||
private func contextTargetIds() -> [UUID] {
|
||
let baseIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
|
||
return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil }
|
||
}
|
||
|
||
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
|
||
let idsToClose = targetIds.filter { id in
|
||
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
|
||
return allowPinned || !tab.isPinned
|
||
}
|
||
for id in idsToClose {
|
||
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
||
tabManager.closeWorkspaceWithConfirmation(tab)
|
||
}
|
||
}
|
||
selectedTabIds.subtract(idsToClose)
|
||
syncSelectionAfterMutation()
|
||
}
|
||
|
||
private func closeOtherTabs(_ targetIds: [UUID]) {
|
||
let keepIds = Set(targetIds)
|
||
let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id }
|
||
closeTabs(idsToClose, allowPinned: false)
|
||
}
|
||
|
||
private func closeTabsBelow(tabId: UUID) {
|
||
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
||
let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id }
|
||
closeTabs(idsToClose, allowPinned: false)
|
||
}
|
||
|
||
private func closeTabsAbove(tabId: UUID) {
|
||
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
||
let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id }
|
||
closeTabs(idsToClose, allowPinned: false)
|
||
}
|
||
|
||
private func markTabsRead(_ targetIds: [UUID]) {
|
||
for id in targetIds {
|
||
notificationStore.markRead(forTabId: id)
|
||
}
|
||
}
|
||
|
||
private func markTabsUnread(_ targetIds: [UUID]) {
|
||
for id in targetIds {
|
||
notificationStore.markUnread(forTabId: id)
|
||
}
|
||
}
|
||
|
||
private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool {
|
||
let targetSet = Set(targetIds)
|
||
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead }
|
||
}
|
||
|
||
private func hasReadNotifications(in targetIds: [UUID]) -> Bool {
|
||
let targetSet = Set(targetIds)
|
||
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead }
|
||
}
|
||
|
||
private func syncSelectionAfterMutation() {
|
||
let existingIds = Set(tabManager.tabs.map { $0.id })
|
||
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
}
|
||
if let selectedId = tabManager.selectedTabId {
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
}
|
||
}
|
||
|
||
private var latestNotificationText: String? {
|
||
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
|
||
let text = notification.body.isEmpty ? notification.title : notification.body
|
||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed.isEmpty ? nil : trimmed
|
||
}
|
||
|
||
private var branchDirectoryRow: String? {
|
||
var parts: [String] = []
|
||
|
||
// Git branch (if enabled and available)
|
||
if sidebarShowGitBranch, let git = tab.gitBranch {
|
||
let dirty = git.isDirty ? "*" : ""
|
||
parts.append("\(git.branch)\(dirty)")
|
||
}
|
||
|
||
// Directory summary
|
||
if let dirs = directorySummaryText {
|
||
parts.append(dirs)
|
||
}
|
||
|
||
let result = parts.joined(separator: " · ")
|
||
return result.isEmpty ? nil : result
|
||
}
|
||
|
||
private var directorySummaryText: String? {
|
||
guard !tab.panels.isEmpty else { return nil }
|
||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||
var seen: Set<String> = []
|
||
var entries: [String] = []
|
||
for panelId in tab.panels.keys {
|
||
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
|
||
let shortened = shortenPath(directory, home: home)
|
||
guard !shortened.isEmpty else { continue }
|
||
if seen.insert(shortened).inserted {
|
||
entries.append(shortened)
|
||
}
|
||
}
|
||
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
||
}
|
||
|
||
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
|
||
switch level {
|
||
case .info: return "circle.fill"
|
||
case .progress: return "arrowtriangle.right.fill"
|
||
case .success: return "checkmark.circle.fill"
|
||
case .warning: return "exclamationmark.triangle.fill"
|
||
case .error: return "xmark.circle.fill"
|
||
}
|
||
}
|
||
|
||
private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color {
|
||
if isActive {
|
||
switch level {
|
||
case .info: return .white.opacity(0.5)
|
||
case .progress: return .white.opacity(0.8)
|
||
case .success: return .white.opacity(0.9)
|
||
case .warning: return .white.opacity(0.9)
|
||
case .error: return .white.opacity(0.9)
|
||
}
|
||
}
|
||
switch level {
|
||
case .info: return .secondary
|
||
case .progress: return .blue
|
||
case .success: return .green
|
||
case .warning: return .orange
|
||
case .error: return .red
|
||
}
|
||
}
|
||
|
||
private func shortenPath(_ path: String, home: String) -> String {
|
||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return path }
|
||
if trimmed == home {
|
||
return "~"
|
||
}
|
||
if trimmed.hasPrefix(home + "/") {
|
||
return "~" + trimmed.dropFirst(home.count)
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
private func promptRename() {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Rename Tab"
|
||
alert.informativeText = "Enter a custom name for this tab."
|
||
let input = NSTextField(string: tab.customTitle ?? tab.title)
|
||
input.placeholderString = "Tab name"
|
||
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
||
alert.accessoryView = input
|
||
alert.addButton(withTitle: "Rename")
|
||
alert.addButton(withTitle: "Cancel")
|
||
let alertWindow = alert.window
|
||
alertWindow.initialFirstResponder = input
|
||
DispatchQueue.main.async {
|
||
alertWindow.makeFirstResponder(input)
|
||
input.selectText(nil)
|
||
}
|
||
let response = alert.runModal()
|
||
guard response == .alertFirstButtonReturn else { return }
|
||
tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue)
|
||
}
|
||
}
|
||
|
||
private struct SidebarStatusPillsRow: View {
|
||
let entries: [SidebarStatusEntry]
|
||
let isActive: Bool
|
||
let onFocus: () -> Void
|
||
|
||
@State private var isExpanded: Bool = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(statusText)
|
||
.font(.system(size: 10))
|
||
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
||
.lineLimit(isExpanded ? nil : 3)
|
||
.truncationMode(.tail)
|
||
.multilineTextAlignment(.leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
onFocus()
|
||
guard shouldShowToggle else { return }
|
||
withAnimation(.easeInOut(duration: 0.15)) {
|
||
isExpanded.toggle()
|
||
}
|
||
}
|
||
|
||
if shouldShowToggle {
|
||
Button(isExpanded ? "Show less" : "Show more") {
|
||
onFocus()
|
||
withAnimation(.easeInOut(duration: 0.15)) {
|
||
isExpanded.toggle()
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.font(.system(size: 10, weight: .semibold))
|
||
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
.help(statusText)
|
||
}
|
||
|
||
private var statusText: String {
|
||
entries
|
||
.map { entry in
|
||
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if !value.isEmpty { return value }
|
||
return entry.key
|
||
}
|
||
.joined(separator: "\n")
|
||
}
|
||
|
||
private var shouldShowToggle: Bool {
|
||
entries.count > 1 || statusText.count > 120
|
||
}
|
||
}
|
||
|
||
enum SidebarDropEdge {
|
||
case top
|
||
case bottom
|
||
}
|
||
|
||
struct SidebarDropIndicator {
|
||
let tabId: UUID?
|
||
let edge: SidebarDropEdge
|
||
}
|
||
|
||
enum SidebarDropPlanner {
|
||
static func indicator(
|
||
draggedTabId: UUID?,
|
||
targetTabId: UUID?,
|
||
tabIds: [UUID],
|
||
pointerY: CGFloat? = nil,
|
||
targetHeight: CGFloat? = nil
|
||
) -> SidebarDropIndicator? {
|
||
guard tabIds.count > 1, let draggedTabId else { return nil }
|
||
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
||
|
||
let insertionPosition: Int
|
||
if let targetTabId {
|
||
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
||
let edge: SidebarDropEdge
|
||
if let pointerY, let targetHeight {
|
||
edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight)
|
||
} else {
|
||
edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
||
}
|
||
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
||
} else {
|
||
insertionPosition = tabIds.count
|
||
}
|
||
|
||
let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
||
guard targetIndex != fromIndex else { return nil }
|
||
return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds)
|
||
}
|
||
|
||
static func targetIndex(
|
||
draggedTabId: UUID,
|
||
targetTabId: UUID?,
|
||
indicator: SidebarDropIndicator?,
|
||
tabIds: [UUID]
|
||
) -> Int? {
|
||
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
||
|
||
let insertionPosition: Int
|
||
if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) {
|
||
insertionPosition = indicatorInsertion
|
||
} else if let targetTabId {
|
||
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
||
let edge = (indicator?.tabId == targetTabId)
|
||
? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds))
|
||
: preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
||
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
||
} else {
|
||
insertionPosition = tabIds.count
|
||
}
|
||
|
||
return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
||
}
|
||
|
||
private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator {
|
||
let clampedInsertion = max(0, min(insertionPosition, tabIds.count))
|
||
if clampedInsertion >= tabIds.count {
|
||
return SidebarDropIndicator(tabId: nil, edge: .bottom)
|
||
}
|
||
return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top)
|
||
}
|
||
|
||
private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? {
|
||
if let tabId = indicator.tabId {
|
||
guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil }
|
||
return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex
|
||
}
|
||
return tabIds.count
|
||
}
|
||
|
||
private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge {
|
||
guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top }
|
||
return fromIndex < targetIndex ? .bottom : .top
|
||
}
|
||
|
||
static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge {
|
||
guard targetHeight > 0 else { return .top }
|
||
let clampedY = min(max(locationY, 0), targetHeight)
|
||
return clampedY < (targetHeight / 2) ? .top : .bottom
|
||
}
|
||
|
||
private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int {
|
||
let clampedInsertion = max(0, min(insertionPosition, totalCount))
|
||
let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion
|
||
return max(0, min(adjusted, max(0, totalCount - 1)))
|
||
}
|
||
}
|
||
|
||
enum SidebarAutoScrollDirection: Equatable {
|
||
case up
|
||
case down
|
||
}
|
||
|
||
struct SidebarAutoScrollPlan: Equatable {
|
||
let direction: SidebarAutoScrollDirection
|
||
let pointsPerTick: CGFloat
|
||
}
|
||
|
||
enum SidebarDragAutoScrollPlanner {
|
||
static let edgeInset: CGFloat = 44
|
||
static let minStep: CGFloat = 2
|
||
static let maxStep: CGFloat = 12
|
||
|
||
static func plan(
|
||
distanceToTop: CGFloat,
|
||
distanceToBottom: CGFloat,
|
||
edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset,
|
||
minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep,
|
||
maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep
|
||
) -> SidebarAutoScrollPlan? {
|
||
guard edgeInset > 0, maxStep >= minStep else { return nil }
|
||
if distanceToTop <= edgeInset {
|
||
let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset))
|
||
let step = minStep + ((maxStep - minStep) * normalized)
|
||
return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step)
|
||
}
|
||
if distanceToBottom <= edgeInset {
|
||
let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset))
|
||
let step = minStep + ((maxStep - minStep) * normalized)
|
||
return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class SidebarDragAutoScrollController: ObservableObject {
|
||
private weak var scrollView: NSScrollView?
|
||
private var timer: Timer?
|
||
private var activePlan: SidebarAutoScrollPlan?
|
||
|
||
func attach(scrollView: NSScrollView?) {
|
||
self.scrollView = scrollView
|
||
}
|
||
|
||
func updateFromDragLocation() {
|
||
guard let scrollView else {
|
||
stop()
|
||
return
|
||
}
|
||
guard let plan = plan(for: scrollView) else {
|
||
stop()
|
||
return
|
||
}
|
||
activePlan = plan
|
||
startTimerIfNeeded()
|
||
}
|
||
|
||
func stop() {
|
||
timer?.invalidate()
|
||
timer = nil
|
||
activePlan = nil
|
||
}
|
||
|
||
private func startTimerIfNeeded() {
|
||
guard timer == nil else { return }
|
||
let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.tick()
|
||
}
|
||
}
|
||
self.timer = timer
|
||
RunLoop.main.add(timer, forMode: .eventTracking)
|
||
}
|
||
|
||
private func tick() {
|
||
guard NSEvent.pressedMouseButtons != 0 else {
|
||
stop()
|
||
return
|
||
}
|
||
guard let scrollView else {
|
||
stop()
|
||
return
|
||
}
|
||
|
||
// AppKit drag/drop autoscroll guidance recommends autoscroll(with:)
|
||
// when periodic drag updates are available; use it first.
|
||
if applyNativeAutoscroll(to: scrollView) {
|
||
activePlan = plan(for: scrollView)
|
||
if activePlan == nil {
|
||
stop()
|
||
}
|
||
return
|
||
}
|
||
|
||
activePlan = self.plan(for: scrollView)
|
||
guard let plan = activePlan else {
|
||
stop()
|
||
return
|
||
}
|
||
_ = apply(plan: plan, to: scrollView)
|
||
}
|
||
|
||
private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool {
|
||
guard let event = NSApp.currentEvent else { return false }
|
||
switch event.type {
|
||
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||
break
|
||
default:
|
||
return false
|
||
}
|
||
|
||
let clipView = scrollView.contentView
|
||
let didScroll = clipView.autoscroll(with: event)
|
||
if didScroll {
|
||
scrollView.reflectScrolledClipView(clipView)
|
||
}
|
||
return didScroll
|
||
}
|
||
|
||
private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) {
|
||
if isFlipped {
|
||
return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y)
|
||
}
|
||
return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y)
|
||
}
|
||
|
||
private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? {
|
||
let viewportHeight = clipView.bounds.height
|
||
guard viewportHeight > 0 else { return nil }
|
||
|
||
let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped)
|
||
return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom)
|
||
}
|
||
|
||
private func mousePoint(in clipView: NSClipView) -> CGPoint {
|
||
let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero
|
||
return clipView.convert(mouseInWindow, from: nil)
|
||
}
|
||
|
||
private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
||
let clipView = scrollView.contentView
|
||
let mouse = mousePoint(in: clipView)
|
||
return planForMousePoint(mouse, in: clipView)
|
||
}
|
||
|
||
private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
||
currentPlan(for: scrollView)
|
||
}
|
||
|
||
private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool {
|
||
guard let documentView = scrollView.documentView else { return false }
|
||
let clipView = scrollView.contentView
|
||
let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height)
|
||
guard maxOriginY > 0 else { return false }
|
||
|
||
let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1
|
||
let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1
|
||
let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick
|
||
let currentY = clipView.bounds.origin.y
|
||
let targetY = min(max(currentY + delta, 0), maxOriginY)
|
||
guard abs(targetY - currentY) > 0.01 else { return false }
|
||
|
||
clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY))
|
||
scrollView.reflectScrolledClipView(clipView)
|
||
return true
|
||
}
|
||
}
|
||
|
||
private enum SidebarTabDragPayload {
|
||
static let typeIdentifier = "com.cmux.sidebar-tab-reorder"
|
||
private static let prefix = "cmux.sidebar-tab."
|
||
|
||
static func provider(for tabId: UUID) -> NSItemProvider {
|
||
let provider = NSItemProvider()
|
||
let payload = "\(prefix)\(tabId.uuidString)"
|
||
provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in
|
||
completion(payload.data(using: .utf8), nil)
|
||
return nil
|
||
}
|
||
return provider
|
||
}
|
||
}
|
||
|
||
private struct SidebarTabDropDelegate: DropDelegate {
|
||
let targetTabId: UUID?
|
||
let tabManager: TabManager
|
||
@Binding var draggedTabId: UUID?
|
||
@Binding var selectedTabIds: Set<UUID>
|
||
@Binding var lastSidebarSelectionIndex: Int?
|
||
let targetRowHeight: CGFloat?
|
||
let dragAutoScrollController: SidebarDragAutoScrollController
|
||
@Binding var dropIndicator: SidebarDropIndicator?
|
||
|
||
func validateDrop(info: DropInfo) -> Bool {
|
||
let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier])
|
||
let hasDrag = draggedTabId != nil
|
||
#if DEBUG
|
||
dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)")
|
||
#endif
|
||
return hasType && hasDrag
|
||
}
|
||
|
||
func dropEntered(info: DropInfo) {
|
||
#if DEBUG
|
||
dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
|
||
#endif
|
||
dragAutoScrollController.updateFromDragLocation()
|
||
updateDropIndicator(for: info)
|
||
}
|
||
|
||
func dropExited(info: DropInfo) {
|
||
if dropIndicator?.tabId == targetTabId {
|
||
dropIndicator = nil
|
||
}
|
||
}
|
||
|
||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||
dragAutoScrollController.updateFromDragLocation()
|
||
updateDropIndicator(for: info)
|
||
return DropProposal(operation: .move)
|
||
}
|
||
|
||
func performDrop(info: DropInfo) -> Bool {
|
||
defer {
|
||
draggedTabId = nil
|
||
dropIndicator = nil
|
||
dragAutoScrollController.stop()
|
||
}
|
||
#if DEBUG
|
||
dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")")
|
||
#endif
|
||
guard let draggedTabId else { return false }
|
||
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false }
|
||
let tabIds = tabManager.tabs.map(\.id)
|
||
guard let targetIndex = SidebarDropPlanner.targetIndex(
|
||
draggedTabId: draggedTabId,
|
||
targetTabId: targetTabId,
|
||
indicator: dropIndicator,
|
||
tabIds: tabIds
|
||
) else {
|
||
return false
|
||
}
|
||
|
||
guard fromIndex != targetIndex else {
|
||
syncSidebarSelection()
|
||
return true
|
||
}
|
||
|
||
_ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex)
|
||
if let selectedId = tabManager.selectedTabId {
|
||
selectedTabIds = [selectedId]
|
||
syncSidebarSelection(preferredSelectedTabId: selectedId)
|
||
} else {
|
||
selectedTabIds = []
|
||
syncSidebarSelection()
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func updateDropIndicator(for info: DropInfo) {
|
||
let tabIds = tabManager.tabs.map(\.id)
|
||
dropIndicator = SidebarDropPlanner.indicator(
|
||
draggedTabId: draggedTabId,
|
||
targetTabId: targetTabId,
|
||
tabIds: tabIds,
|
||
pointerY: targetTabId == nil ? nil : info.location.y,
|
||
targetHeight: targetRowHeight
|
||
)
|
||
}
|
||
|
||
private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) {
|
||
let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId
|
||
if let selectedId {
|
||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||
} else {
|
||
lastSidebarSelectionIndex = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// AppKit-level double-click handler for the sidebar title-bar area.
|
||
/// Uses NSView hit-testing so it isn't swallowed by the SwiftUI ScrollView underneath.
|
||
private struct DoubleClickZoomView: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSView {
|
||
DoubleClickZoomNSView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||
|
||
private final class DoubleClickZoomNSView: NSView {
|
||
override var mouseDownCanMoveWindow: Bool { true }
|
||
override func hitTest(_ point: NSPoint) -> NSView? { self }
|
||
override func mouseDown(with event: NSEvent) {
|
||
if event.clickCount == 2 {
|
||
window?.zoom(nil)
|
||
} else {
|
||
super.mouseDown(with: event)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct MiddleClickCapture: NSViewRepresentable {
|
||
let onMiddleClick: () -> Void
|
||
|
||
func makeNSView(context: Context) -> MiddleClickCaptureView {
|
||
let view = MiddleClickCaptureView()
|
||
view.onMiddleClick = onMiddleClick
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) {
|
||
nsView.onMiddleClick = onMiddleClick
|
||
}
|
||
}
|
||
|
||
private final class MiddleClickCaptureView: NSView {
|
||
var onMiddleClick: (() -> Void)?
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||
// Only intercept middle-click so left-click selection and right-click context menus
|
||
// continue to hit-test through to SwiftUI/AppKit normally.
|
||
guard let event = NSApp.currentEvent,
|
||
event.type == .otherMouseDown,
|
||
event.buttonNumber == 2 else {
|
||
return nil
|
||
}
|
||
return self
|
||
}
|
||
|
||
override func otherMouseDown(with event: NSEvent) {
|
||
guard event.buttonNumber == 2 else {
|
||
super.otherMouseDown(with: event)
|
||
return
|
||
}
|
||
onMiddleClick?()
|
||
}
|
||
}
|
||
|
||
enum SidebarSelection {
|
||
case tabs
|
||
case notifications
|
||
}
|
||
|
||
private struct ClearScrollBackground: ViewModifier {
|
||
func body(content: Content) -> some View {
|
||
if #available(macOS 13.0, *) {
|
||
content
|
||
.scrollContentBackground(.hidden)
|
||
.background(ScrollBackgroundClearer())
|
||
} else {
|
||
content
|
||
.background(ScrollBackgroundClearer())
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct ScrollBackgroundClearer: NSViewRepresentable {
|
||
func makeNSView(context: Context) -> NSView {
|
||
NSView()
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
DispatchQueue.main.async {
|
||
guard let scrollView = findScrollView(startingAt: nsView) else { return }
|
||
// Clear all backgrounds and mark as non-opaque for transparency
|
||
scrollView.drawsBackground = false
|
||
scrollView.backgroundColor = .clear
|
||
scrollView.wantsLayer = true
|
||
scrollView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
scrollView.layer?.isOpaque = false
|
||
|
||
scrollView.contentView.drawsBackground = false
|
||
scrollView.contentView.backgroundColor = .clear
|
||
scrollView.contentView.wantsLayer = true
|
||
scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
scrollView.contentView.layer?.isOpaque = false
|
||
|
||
if let docView = scrollView.documentView {
|
||
docView.wantsLayer = true
|
||
docView.layer?.backgroundColor = NSColor.clear.cgColor
|
||
docView.layer?.isOpaque = false
|
||
}
|
||
}
|
||
}
|
||
|
||
private func findScrollView(startingAt view: NSView) -> NSScrollView? {
|
||
var current: NSView? = view
|
||
while let candidate = current {
|
||
if let scrollView = candidate as? NSScrollView {
|
||
return scrollView
|
||
}
|
||
current = candidate.superview
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private struct DraggableFolderIcon: View {
|
||
let directory: String
|
||
|
||
var body: some View {
|
||
DraggableFolderIconRepresentable(directory: directory)
|
||
.frame(width: 16, height: 16)
|
||
.help("Drag to open in Finder or another app")
|
||
.onTapGesture(count: 2) {
|
||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct DraggableFolderIconRepresentable: NSViewRepresentable {
|
||
let directory: String
|
||
|
||
func makeNSView(context: Context) -> DraggableFolderNSView {
|
||
DraggableFolderNSView(directory: directory)
|
||
}
|
||
|
||
func updateNSView(_ nsView: DraggableFolderNSView, context: Context) {
|
||
nsView.directory = directory
|
||
nsView.updateIcon()
|
||
}
|
||
}
|
||
|
||
private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||
var directory: String
|
||
private var imageView: NSImageView!
|
||
|
||
init(directory: String) {
|
||
self.directory = directory
|
||
super.init(frame: .zero)
|
||
setupImageView()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override var intrinsicContentSize: NSSize {
|
||
NSSize(width: 16, height: 16)
|
||
}
|
||
|
||
private func setupImageView() {
|
||
imageView = NSImageView()
|
||
imageView.imageScaling = .scaleProportionallyDown
|
||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||
addSubview(imageView)
|
||
NSLayoutConstraint.activate([
|
||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
imageView.topAnchor.constraint(equalTo: topAnchor),
|
||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||
imageView.widthAnchor.constraint(equalToConstant: 16),
|
||
imageView.heightAnchor.constraint(equalToConstant: 16),
|
||
])
|
||
updateIcon()
|
||
}
|
||
|
||
func updateIcon() {
|
||
let icon = NSWorkspace.shared.icon(forFile: directory)
|
||
icon.size = NSSize(width: 16, height: 16)
|
||
imageView.image = icon
|
||
}
|
||
|
||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||
return context == .outsideApplication ? [.copy, .link] : .copy
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
#if DEBUG
|
||
dlog("folder.dragStart dir=\(directory)")
|
||
#endif
|
||
let fileURL = URL(fileURLWithPath: directory)
|
||
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
|
||
|
||
let iconImage = NSWorkspace.shared.icon(forFile: directory)
|
||
iconImage.size = NSSize(width: 32, height: 32)
|
||
draggingItem.setDraggingFrame(bounds, contents: iconImage)
|
||
|
||
beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||
}
|
||
|
||
override func rightMouseDown(with event: NSEvent) {
|
||
let menu = buildPathMenu()
|
||
// Pop up menu at bottom-left of icon (like native proxy icon)
|
||
let menuLocation = NSPoint(x: 0, y: bounds.height)
|
||
menu.popUp(positioning: nil, at: menuLocation, in: self)
|
||
}
|
||
|
||
private func buildPathMenu() -> NSMenu {
|
||
let menu = NSMenu()
|
||
let url = URL(fileURLWithPath: directory).standardized
|
||
var pathComponents: [URL] = []
|
||
|
||
// Build path from current directory up to root
|
||
var current = url
|
||
while current.path != "/" {
|
||
pathComponents.append(current)
|
||
current = current.deletingLastPathComponent()
|
||
}
|
||
pathComponents.append(URL(fileURLWithPath: "/"))
|
||
|
||
// Add path components (current dir at top, root at bottom - matches native macOS)
|
||
for pathURL in pathComponents {
|
||
let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
|
||
icon.size = NSSize(width: 16, height: 16)
|
||
|
||
let displayName: String
|
||
if pathURL.path == "/" {
|
||
// Use the volume name for root
|
||
if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName {
|
||
displayName = volumeName
|
||
} else {
|
||
displayName = "Macintosh HD"
|
||
}
|
||
} else {
|
||
displayName = FileManager.default.displayName(atPath: pathURL.path)
|
||
}
|
||
|
||
let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "")
|
||
item.target = self
|
||
item.image = icon
|
||
item.representedObject = pathURL
|
||
menu.addItem(item)
|
||
}
|
||
|
||
// Add computer name at the bottom (like native proxy icon)
|
||
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||
let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
|
||
computerIcon.size = NSSize(width: 16, height: 16)
|
||
|
||
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
|
||
computerItem.target = self
|
||
computerItem.image = computerIcon
|
||
menu.addItem(computerItem)
|
||
|
||
return menu
|
||
}
|
||
|
||
@objc private func openPathComponent(_ sender: NSMenuItem) {
|
||
guard let url = sender.representedObject as? URL else { return }
|
||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path)
|
||
}
|
||
|
||
@objc private func openComputer(_ sender: NSMenuItem) {
|
||
// Open "Computer" view in Finder (shows all volumes)
|
||
NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true))
|
||
}
|
||
}
|
||
|
||
/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested
|
||
private struct SidebarVisualEffectBackground: NSViewRepresentable {
|
||
let material: NSVisualEffectView.Material
|
||
let blendingMode: NSVisualEffectView.BlendingMode
|
||
let state: NSVisualEffectView.State
|
||
let opacity: Double
|
||
let tintColor: NSColor?
|
||
let cornerRadius: CGFloat
|
||
let preferLiquidGlass: Bool
|
||
|
||
init(
|
||
material: NSVisualEffectView.Material = .hudWindow,
|
||
blendingMode: NSVisualEffectView.BlendingMode = .behindWindow,
|
||
state: NSVisualEffectView.State = .active,
|
||
opacity: Double = 1.0,
|
||
tintColor: NSColor? = nil,
|
||
cornerRadius: CGFloat = 0,
|
||
preferLiquidGlass: Bool = false
|
||
) {
|
||
self.material = material
|
||
self.blendingMode = blendingMode
|
||
self.state = state
|
||
self.opacity = opacity
|
||
self.tintColor = tintColor
|
||
self.cornerRadius = cornerRadius
|
||
self.preferLiquidGlass = preferLiquidGlass
|
||
}
|
||
|
||
static var liquidGlassAvailable: Bool {
|
||
NSClassFromString("NSGlassEffectView") != nil
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
// Try NSGlassEffectView if preferred or if we want to test availability
|
||
if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type {
|
||
let glass = glassClass.init(frame: .zero)
|
||
glass.autoresizingMask = [.width, .height]
|
||
glass.wantsLayer = true
|
||
return glass
|
||
}
|
||
|
||
// Use NSVisualEffectView
|
||
let view = NSVisualEffectView()
|
||
view.autoresizingMask = [.width, .height]
|
||
view.wantsLayer = true
|
||
view.layerContentsRedrawPolicy = .onSetNeedsDisplay
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
// Configure based on view type
|
||
if nsView.className == "NSGlassEffectView" {
|
||
// NSGlassEffectView configuration via private API
|
||
nsView.alphaValue = max(0.0, min(1.0, opacity))
|
||
nsView.layer?.cornerRadius = cornerRadius
|
||
nsView.layer?.masksToBounds = cornerRadius > 0
|
||
|
||
// Try to set tint color via private selector
|
||
if let color = tintColor {
|
||
let selector = NSSelectorFromString("setTintColor:")
|
||
if nsView.responds(to: selector) {
|
||
nsView.perform(selector, with: color)
|
||
}
|
||
}
|
||
} else if let visualEffect = nsView as? NSVisualEffectView {
|
||
// NSVisualEffectView configuration
|
||
visualEffect.material = material
|
||
visualEffect.blendingMode = blendingMode
|
||
visualEffect.state = state
|
||
visualEffect.alphaValue = max(0.0, min(1.0, opacity))
|
||
visualEffect.layer?.cornerRadius = cornerRadius
|
||
visualEffect.layer?.masksToBounds = cornerRadius > 0
|
||
visualEffect.needsDisplay = true
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// Reads the leading inset required to clear traffic lights + left titlebar accessories.
|
||
private struct TitlebarLeadingInsetReader: NSViewRepresentable {
|
||
@Binding var inset: CGFloat
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = NSView()
|
||
view.setFrameSize(.zero)
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
DispatchQueue.main.async {
|
||
guard let window = nsView.window else { return }
|
||
// Start past the traffic lights
|
||
var leading: CGFloat = 78
|
||
// Add width of all left-aligned titlebar accessories
|
||
for accessory in window.titlebarAccessoryViewControllers
|
||
where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left {
|
||
leading += accessory.view.frame.width
|
||
}
|
||
leading += 0
|
||
if leading != inset {
|
||
inset = leading
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct SidebarBackdrop: View {
|
||
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
|
||
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
|
||
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
|
||
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
||
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
||
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
|
||
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
|
||
|
||
var body: some View {
|
||
let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial)
|
||
let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow
|
||
let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active
|
||
let tintColor = (NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
|
||
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
|
||
let useLiquidGlass = materialOption?.usesLiquidGlass ?? false
|
||
let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow
|
||
|
||
return ZStack {
|
||
if let material = materialOption?.material {
|
||
// When using liquidGlass + behindWindow, window handles glass + tint
|
||
// Sidebar is fully transparent
|
||
if !useWindowLevelGlass {
|
||
SidebarVisualEffectBackground(
|
||
material: material,
|
||
blendingMode: blendingMode,
|
||
state: state,
|
||
opacity: sidebarBlurOpacity,
|
||
tintColor: tintColor,
|
||
cornerRadius: cornerRadius,
|
||
preferLiquidGlass: useLiquidGlass
|
||
)
|
||
// Tint overlay for NSVisualEffectView fallback
|
||
if !useLiquidGlass {
|
||
Color(nsColor: tintColor)
|
||
}
|
||
}
|
||
}
|
||
// When material is none or useWindowLevelGlass, render nothing
|
||
}
|
||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||
}
|
||
}
|
||
|
||
enum SidebarMaterialOption: String, CaseIterable, Identifiable {
|
||
case none
|
||
case liquidGlass // macOS 26+ NSGlassEffectView
|
||
case sidebar
|
||
case hudWindow
|
||
case menu
|
||
case popover
|
||
case underWindowBackground
|
||
case windowBackground
|
||
case contentBackground
|
||
case fullScreenUI
|
||
case sheet
|
||
case headerView
|
||
case toolTip
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .none: return "None"
|
||
case .liquidGlass: return "Liquid Glass (macOS 26+)"
|
||
case .sidebar: return "Sidebar"
|
||
case .hudWindow: return "HUD Window"
|
||
case .menu: return "Menu"
|
||
case .popover: return "Popover"
|
||
case .underWindowBackground: return "Under Window"
|
||
case .windowBackground: return "Window Background"
|
||
case .contentBackground: return "Content Background"
|
||
case .fullScreenUI: return "Full Screen UI"
|
||
case .sheet: return "Sheet"
|
||
case .headerView: return "Header View"
|
||
case .toolTip: return "Tool Tip"
|
||
}
|
||
}
|
||
|
||
/// Returns true if this option should use NSGlassEffectView (macOS 26+)
|
||
var usesLiquidGlass: Bool {
|
||
self == .liquidGlass
|
||
}
|
||
|
||
var material: NSVisualEffectView.Material? {
|
||
switch self {
|
||
case .none: return nil
|
||
case .liquidGlass: return .underWindowBackground // Fallback material
|
||
case .sidebar: return .sidebar
|
||
case .hudWindow: return .hudWindow
|
||
case .menu: return .menu
|
||
case .popover: return .popover
|
||
case .underWindowBackground: return .underWindowBackground
|
||
case .windowBackground: return .windowBackground
|
||
case .contentBackground: return .contentBackground
|
||
case .fullScreenUI: return .fullScreenUI
|
||
case .sheet: return .sheet
|
||
case .headerView: return .headerView
|
||
case .toolTip: return .toolTip
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarBlendModeOption: String, CaseIterable, Identifiable {
|
||
case behindWindow
|
||
case withinWindow
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .behindWindow: return "Behind Window"
|
||
case .withinWindow: return "Within Window"
|
||
}
|
||
}
|
||
|
||
var mode: NSVisualEffectView.BlendingMode {
|
||
switch self {
|
||
case .behindWindow: return .behindWindow
|
||
case .withinWindow: return .withinWindow
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarStateOption: String, CaseIterable, Identifiable {
|
||
case active
|
||
case inactive
|
||
case followWindow
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .active: return "Active"
|
||
case .inactive: return "Inactive"
|
||
case .followWindow: return "Follow Window"
|
||
}
|
||
}
|
||
|
||
var state: NSVisualEffectView.State {
|
||
switch self {
|
||
case .active: return .active
|
||
case .inactive: return .inactive
|
||
case .followWindow: return .followsWindowActiveState
|
||
}
|
||
}
|
||
}
|
||
|
||
enum SidebarPresetOption: String, CaseIterable, Identifiable {
|
||
case nativeSidebar
|
||
case glassBehind
|
||
case softBlur
|
||
case popoverGlass
|
||
case hudGlass
|
||
case underWindow
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .nativeSidebar: return "Native Sidebar"
|
||
case .glassBehind: return "Raycast Gray"
|
||
case .softBlur: return "Soft Blur"
|
||
case .popoverGlass: return "Popover Glass"
|
||
case .hudGlass: return "HUD Glass"
|
||
case .underWindow: return "Under Window"
|
||
}
|
||
}
|
||
|
||
var material: SidebarMaterialOption {
|
||
switch self {
|
||
case .nativeSidebar: return .sidebar
|
||
case .glassBehind: return .sidebar
|
||
case .softBlur: return .sidebar
|
||
case .popoverGlass: return .popover
|
||
case .hudGlass: return .hudWindow
|
||
case .underWindow: return .underWindowBackground
|
||
}
|
||
}
|
||
|
||
var blendMode: SidebarBlendModeOption {
|
||
switch self {
|
||
case .nativeSidebar: return .withinWindow
|
||
case .glassBehind: return .behindWindow
|
||
case .softBlur: return .behindWindow
|
||
case .popoverGlass: return .behindWindow
|
||
case .hudGlass: return .withinWindow
|
||
case .underWindow: return .withinWindow
|
||
}
|
||
}
|
||
|
||
var state: SidebarStateOption {
|
||
switch self {
|
||
case .nativeSidebar: return .followWindow
|
||
case .glassBehind: return .active
|
||
case .softBlur: return .active
|
||
case .popoverGlass: return .active
|
||
case .hudGlass: return .active
|
||
case .underWindow: return .followWindow
|
||
}
|
||
}
|
||
|
||
var tintHex: String {
|
||
switch self {
|
||
case .nativeSidebar: return "#000000"
|
||
case .glassBehind: return "#000000"
|
||
case .softBlur: return "#000000"
|
||
case .popoverGlass: return "#000000"
|
||
case .hudGlass: return "#000000"
|
||
case .underWindow: return "#000000"
|
||
}
|
||
}
|
||
|
||
var tintOpacity: Double {
|
||
switch self {
|
||
case .nativeSidebar: return 0.18
|
||
case .glassBehind: return 0.36
|
||
case .softBlur: return 0.28
|
||
case .popoverGlass: return 0.10
|
||
case .hudGlass: return 0.62
|
||
case .underWindow: return 0.14
|
||
}
|
||
}
|
||
|
||
var cornerRadius: Double {
|
||
switch self {
|
||
case .nativeSidebar: return 0.0
|
||
case .glassBehind: return 0.0
|
||
case .softBlur: return 0.0
|
||
case .popoverGlass: return 10.0
|
||
case .hudGlass: return 10.0
|
||
case .underWindow: return 6.0
|
||
}
|
||
}
|
||
|
||
var blurOpacity: Double {
|
||
switch self {
|
||
case .nativeSidebar: return 1.0
|
||
case .glassBehind: return 0.6
|
||
case .softBlur: return 0.45
|
||
case .popoverGlass: return 0.9
|
||
case .hudGlass: return 0.98
|
||
case .underWindow: return 0.9
|
||
}
|
||
}
|
||
}
|
||
|
||
extension NSColor {
|
||
func hexString() -> String {
|
||
let color = usingColorSpace(.sRGB) ?? self
|
||
var red: CGFloat = 0
|
||
var green: CGFloat = 0
|
||
var blue: CGFloat = 0
|
||
var alpha: CGFloat = 0
|
||
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||
return String(
|
||
format: "#%02X%02X%02X",
|
||
min(255, max(0, Int(red * 255))),
|
||
min(255, max(0, Int(green * 255))),
|
||
min(255, max(0, Int(blue * 255)))
|
||
)
|
||
}
|
||
}
|