Move GeometryReader from wrapping the entire VStack to wrapping only the ScrollView so proxy.size.height reflects available height (minus pill), preventing unnecessary scrollability that triggered macOS horizontal insets. Also clamp update pill text width with maxWidth instead of fixed width so it truncates gracefully at narrow sidebar widths and grows when wider, add horizontal padding, left-align truncated text, and add debug menu item for testing with long nightly version strings.
2506 lines
94 KiB
Swift
2506 lines
94 KiB
Swift
import AppKit
|
|
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()
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@ObservedObject var updateViewModel: UpdateViewModel
|
|
let windowId: UUID
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@EnvironmentObject var sidebarState: SidebarState
|
|
@EnvironmentObject var sidebarSelectionState: SidebarSelectionState
|
|
@State private var sidebarWidth: CGFloat = 200
|
|
@State private var sidebarMinX: CGFloat = 0
|
|
@State private var isResizerHovering = false
|
|
@State private var isResizerDragging = false
|
|
private let sidebarHandleWidth: CGFloat = 6
|
|
@State private var selectedTabIds: Set<UUID> = []
|
|
@State private var lastSidebarSelectionIndex: Int? = nil
|
|
@State private var titlebarText: String = ""
|
|
|
|
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 !isResizerHovering {
|
|
NSCursor.resizeLeftRight.push()
|
|
isResizerHovering = true
|
|
}
|
|
}
|
|
let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
|
|
withTransaction(Transaction(animation: nil)) {
|
|
sidebarWidth = nextWidth
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
if isResizerDragging {
|
|
isResizerDragging = false
|
|
if !isResizerHovering {
|
|
NSCursor.pop()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
|
|
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
|
|
@State private var titlebarPadding: CGFloat = 32
|
|
|
|
private var terminalContent: some View {
|
|
ZStack {
|
|
ZStack {
|
|
ForEach(tabManager.tabs) { tab in
|
|
let isActive = tabManager.selectedTabId == tab.id
|
|
WorkspaceContentView(workspace: tab, isTabActive: isActive)
|
|
.opacity(isActive ? 1 : 0)
|
|
.allowsHitTesting(isActive)
|
|
}
|
|
}
|
|
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
|
|
|
|
NotificationsPage(selection: $sidebarSelectionState.selection)
|
|
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
|
|
}
|
|
.padding(.top, titlebarPadding)
|
|
.overlay(alignment: .top) {
|
|
// Titlebar overlay is only over terminal content, not the sidebar.
|
|
customTitlebar
|
|
}
|
|
}
|
|
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
|
|
|
// Background glass settings
|
|
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
|
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
|
|
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
|
|
|
@State private var titlebarLeadingInset: CGFloat = 12
|
|
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
|
|
private var fakeTitlebarBackground: Color {
|
|
if colorScheme == .light {
|
|
return Color(nsColor: .windowBackgroundColor)
|
|
}
|
|
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
|
|
let alpha: CGFloat = ghosttyBackground.isLightColor ? 0.94 : 0.86
|
|
return Color(nsColor: ghosttyBackground.withAlphaComponent(alpha))
|
|
}
|
|
private var fakeTitlebarTextColor: Color {
|
|
colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.78) : .secondary
|
|
}
|
|
private var fakeTitlebarSeparatorColor: Color {
|
|
Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34)
|
|
}
|
|
|
|
private var 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) {
|
|
// 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, sidebarState.isVisible ? 12 : titlebarLeadingInset)
|
|
.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
|
|
}
|
|
}
|
|
}
|
|
.frame(minWidth: 800, minHeight: 600)
|
|
.background(Color.clear)
|
|
.onAppear {
|
|
tabManager.applyWindowBackgroundForSelectedTab()
|
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
|
selectedTabIds = [selectedId]
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
|
}
|
|
updateTitlebarText()
|
|
}
|
|
.onChange(of: tabManager.selectedTabId) { newValue in
|
|
tabManager.applyWindowBackgroundForSelectedTab()
|
|
guard let newValue else { return }
|
|
if selectedTabIds.count <= 1 {
|
|
selectedTabIds = [newValue]
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue }
|
|
}
|
|
updateTitlebarText()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
|
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
|
tabId == tabManager.selectedTabId else { return }
|
|
updateTitlebarText()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in
|
|
sidebarSelectionState.selection = .tabs
|
|
updateTitlebarText()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
|
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
|
tabId == tabManager.selectedTabId else { return }
|
|
updateTitlebarText()
|
|
}
|
|
.onReceive(tabManager.$tabs) { tabs in
|
|
let existingIds = Set(tabs.map { $0.id })
|
|
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
|
selectedTabIds = [selectedId]
|
|
}
|
|
if let lastIndex = lastSidebarSelectionIndex, lastIndex >= tabs.count {
|
|
if let selectedId = tabManager.selectedTabId {
|
|
lastSidebarSelectionIndex = tabs.firstIndex { $0.id == selectedId }
|
|
} else {
|
|
lastSidebarSelectionIndex = nil
|
|
}
|
|
}
|
|
}
|
|
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
|
|
sidebarMinX = frame.minX
|
|
}
|
|
.onChange(of: bgGlassTintHex) { _ in
|
|
updateWindowGlassTint()
|
|
}
|
|
.onChange(of: bgGlassTintOpacity) { _ in
|
|
updateWindowGlassTint()
|
|
}
|
|
.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)
|
|
|
|
// 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
|
|
)
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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
|
|
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)
|
|
}
|
|
.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: { tabManager.closeWorkspaceWithConfirmation(tab) }) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))")
|
|
.frame(width: 16, height: 16, alignment: .center)
|
|
.opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0)
|
|
.allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint)
|
|
|
|
if showsWorkspaceShortcutHint, let workspaceShortcutLabel {
|
|
Text(workspaceShortcutLabel)
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundColor(isActive ? .white : .primary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9))
|
|
.offset(
|
|
x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset),
|
|
y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
|
|
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
|
|
}
|
|
|
|
if let subtitle = latestNotificationText {
|
|
Text(subtitle)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
if sidebarShowStatusPills, !tab.statusEntries.isEmpty {
|
|
SidebarStatusPillsRow(
|
|
entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
|
|
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
|
return lhs.key < rhs.key
|
|
}),
|
|
isActive: isActive,
|
|
onFocus: { updateSelection() }
|
|
)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Latest log entry
|
|
if sidebarShowLog, let latestLog = tab.logEntries.last {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: logLevelIcon(latestLog.level))
|
|
.font(.system(size: 8))
|
|
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive))
|
|
Text(latestLog.message)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Progress bar
|
|
if sidebarShowProgress, let progress = tab.progress {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2))
|
|
Capsule()
|
|
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor)
|
|
.frame(width: max(0, geo.size.width * CGFloat(progress.value)))
|
|
}
|
|
}
|
|
.frame(height: 3)
|
|
|
|
if let label = progress.label {
|
|
Text(label)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Branch + directory + ports row
|
|
if let dirRow = branchDirectoryRow {
|
|
HStack(spacing: 3) {
|
|
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
|
}
|
|
Text(dirRow)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
|
|
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(backgroundColor)
|
|
)
|
|
.padding(.horizontal, 6)
|
|
.background {
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
rowHeight = max(proxy.size.height, 1)
|
|
}
|
|
.onChange(of: proxy.size.height) { newHeight in
|
|
rowHeight = max(newHeight, 1)
|
|
}
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.opacity(isBeingDragged ? 0.6 : 1)
|
|
.overlay {
|
|
MiddleClickCapture {
|
|
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 {
|
|
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() {
|
|
let modifiers = NSEvent.modifierFlags
|
|
let isCommand = modifiers.contains(.command)
|
|
let isShift = modifiers.contains(.shift)
|
|
|
|
if isShift, let lastIndex = lastSidebarSelectionIndex {
|
|
let lower = min(lastIndex, index)
|
|
let upper = max(lastIndex, index)
|
|
let rangeIds = tabManager.tabs[lower...upper].map { $0.id }
|
|
if isCommand {
|
|
selectedTabIds.formUnion(rangeIds)
|
|
} else {
|
|
selectedTabIds = Set(rangeIds)
|
|
}
|
|
} else if isCommand {
|
|
if selectedTabIds.contains(tab.id) {
|
|
selectedTabIds.remove(tab.id)
|
|
} else {
|
|
selectedTabIds.insert(tab.id)
|
|
}
|
|
} else {
|
|
selectedTabIds = [tab.id]
|
|
}
|
|
|
|
lastSidebarSelectionIndex = index
|
|
tabManager.selectTab(tab)
|
|
selection = .tabs
|
|
}
|
|
|
|
private func contextTargetIds() -> [UUID] {
|
|
let baseIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
|
|
return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil }
|
|
}
|
|
|
|
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
|
|
let idsToClose = targetIds.filter { id in
|
|
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
|
|
return allowPinned || !tab.isPinned
|
|
}
|
|
for id in idsToClose {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
|
tabManager.closeWorkspaceWithConfirmation(tab)
|
|
}
|
|
}
|
|
selectedTabIds.subtract(idsToClose)
|
|
syncSelectionAfterMutation()
|
|
}
|
|
|
|
private func closeOtherTabs(_ targetIds: [UUID]) {
|
|
let keepIds = Set(targetIds)
|
|
let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func closeTabsBelow(tabId: UUID) {
|
|
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func closeTabsAbove(tabId: UUID) {
|
|
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func markTabsRead(_ targetIds: [UUID]) {
|
|
for id in targetIds {
|
|
notificationStore.markRead(forTabId: id)
|
|
}
|
|
}
|
|
|
|
private func markTabsUnread(_ targetIds: [UUID]) {
|
|
for id in targetIds {
|
|
notificationStore.markUnread(forTabId: id)
|
|
}
|
|
}
|
|
|
|
private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool {
|
|
let targetSet = Set(targetIds)
|
|
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead }
|
|
}
|
|
|
|
private func hasReadNotifications(in targetIds: [UUID]) -> Bool {
|
|
let targetSet = Set(targetIds)
|
|
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead }
|
|
}
|
|
|
|
private func syncSelectionAfterMutation() {
|
|
let existingIds = Set(tabManager.tabs.map { $0.id })
|
|
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
|
selectedTabIds = [selectedId]
|
|
}
|
|
if let selectedId = tabManager.selectedTabId {
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
|
}
|
|
}
|
|
|
|
private var latestNotificationText: String? {
|
|
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
|
|
let text = notification.body.isEmpty ? notification.title : notification.body
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private var branchDirectoryRow: String? {
|
|
var parts: [String] = []
|
|
|
|
// Git branch (if enabled and available)
|
|
if sidebarShowGitBranch, let git = tab.gitBranch {
|
|
let dirty = git.isDirty ? "*" : ""
|
|
parts.append("\(git.branch)\(dirty)")
|
|
}
|
|
|
|
// Directory summary
|
|
if let dirs = directorySummaryText {
|
|
parts.append(dirs)
|
|
}
|
|
|
|
// Ports (if enabled and available)
|
|
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
|
let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",")
|
|
parts.append(portsStr)
|
|
}
|
|
|
|
let result = parts.joined(separator: " · ")
|
|
return result.isEmpty ? nil : result
|
|
}
|
|
|
|
private var directorySummaryText: String? {
|
|
guard !tab.panels.isEmpty else { return nil }
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
var seen: Set<String> = []
|
|
var entries: [String] = []
|
|
for panelId in tab.panels.keys {
|
|
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
|
|
let shortened = shortenPath(directory, home: home)
|
|
guard !shortened.isEmpty else { continue }
|
|
if seen.insert(shortened).inserted {
|
|
entries.append(shortened)
|
|
}
|
|
}
|
|
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
|
}
|
|
|
|
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
|
|
switch level {
|
|
case .info: return "circle.fill"
|
|
case .progress: return "arrowtriangle.right.fill"
|
|
case .success: return "checkmark.circle.fill"
|
|
case .warning: return "exclamationmark.triangle.fill"
|
|
case .error: return "xmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color {
|
|
if isActive {
|
|
switch level {
|
|
case .info: return .white.opacity(0.5)
|
|
case .progress: return .white.opacity(0.8)
|
|
case .success: return .white.opacity(0.9)
|
|
case .warning: return .white.opacity(0.9)
|
|
case .error: return .white.opacity(0.9)
|
|
}
|
|
}
|
|
switch level {
|
|
case .info: return .secondary
|
|
case .progress: return .blue
|
|
case .success: return .green
|
|
case .warning: return .orange
|
|
case .error: return .red
|
|
}
|
|
}
|
|
|
|
private func shortenPath(_ path: String, home: String) -> String {
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return path }
|
|
if trimmed == home {
|
|
return "~"
|
|
}
|
|
if trimmed.hasPrefix(home + "/") {
|
|
return "~" + trimmed.dropFirst(home.count)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private func promptRename() {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Tab"
|
|
alert.informativeText = "Enter a custom name for this tab."
|
|
let input = NSTextField(string: tab.customTitle ?? tab.title)
|
|
input.placeholderString = "Tab name"
|
|
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let alertWindow = alert.window
|
|
alertWindow.initialFirstResponder = input
|
|
DispatchQueue.main.async {
|
|
alertWindow.makeFirstResponder(input)
|
|
input.selectText(nil)
|
|
}
|
|
let response = alert.runModal()
|
|
guard response == .alertFirstButtonReturn else { return }
|
|
tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue)
|
|
}
|
|
}
|
|
|
|
private struct SidebarStatusPillsRow: View {
|
|
let entries: [SidebarStatusEntry]
|
|
let isActive: Bool
|
|
let onFocus: () -> Void
|
|
|
|
@State private var isExpanded: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(statusText)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(isExpanded ? nil : 3)
|
|
.truncationMode(.tail)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onFocus()
|
|
guard shouldShowToggle else { return }
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isExpanded.toggle()
|
|
}
|
|
}
|
|
|
|
if shouldShowToggle {
|
|
Button(isExpanded ? "Show less" : "Show more") {
|
|
onFocus()
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isExpanded.toggle()
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.help(statusText)
|
|
}
|
|
|
|
private var statusText: String {
|
|
entries
|
|
.map { entry in
|
|
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { return value }
|
|
return entry.key
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
private var shouldShowToggle: Bool {
|
|
entries.count > 1 || statusText.count > 120
|
|
}
|
|
}
|
|
|
|
enum SidebarDropEdge {
|
|
case top
|
|
case bottom
|
|
}
|
|
|
|
struct SidebarDropIndicator {
|
|
let tabId: UUID?
|
|
let edge: SidebarDropEdge
|
|
}
|
|
|
|
enum SidebarDropPlanner {
|
|
static func indicator(
|
|
draggedTabId: UUID?,
|
|
targetTabId: UUID?,
|
|
tabIds: [UUID],
|
|
pointerY: CGFloat? = nil,
|
|
targetHeight: CGFloat? = nil
|
|
) -> SidebarDropIndicator? {
|
|
guard tabIds.count > 1, let draggedTabId else { return nil }
|
|
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
|
|
|
let insertionPosition: Int
|
|
if let targetTabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
|
let edge: SidebarDropEdge
|
|
if let pointerY, let targetHeight {
|
|
edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight)
|
|
} else {
|
|
edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
|
}
|
|
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
|
} else {
|
|
insertionPosition = tabIds.count
|
|
}
|
|
|
|
let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
|
guard targetIndex != fromIndex else { return nil }
|
|
return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds)
|
|
}
|
|
|
|
static func targetIndex(
|
|
draggedTabId: UUID,
|
|
targetTabId: UUID?,
|
|
indicator: SidebarDropIndicator?,
|
|
tabIds: [UUID]
|
|
) -> Int? {
|
|
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
|
|
|
let insertionPosition: Int
|
|
if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) {
|
|
insertionPosition = indicatorInsertion
|
|
} else if let targetTabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
|
let edge = (indicator?.tabId == targetTabId)
|
|
? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds))
|
|
: preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
|
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
|
} else {
|
|
insertionPosition = tabIds.count
|
|
}
|
|
|
|
return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
|
}
|
|
|
|
private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator {
|
|
let clampedInsertion = max(0, min(insertionPosition, tabIds.count))
|
|
if clampedInsertion >= tabIds.count {
|
|
return SidebarDropIndicator(tabId: nil, edge: .bottom)
|
|
}
|
|
return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top)
|
|
}
|
|
|
|
private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? {
|
|
if let tabId = indicator.tabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil }
|
|
return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex
|
|
}
|
|
return tabIds.count
|
|
}
|
|
|
|
private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge {
|
|
guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top }
|
|
return fromIndex < targetIndex ? .bottom : .top
|
|
}
|
|
|
|
static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge {
|
|
guard targetHeight > 0 else { return .top }
|
|
let clampedY = min(max(locationY, 0), targetHeight)
|
|
return clampedY < (targetHeight / 2) ? .top : .bottom
|
|
}
|
|
|
|
private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int {
|
|
let clampedInsertion = max(0, min(insertionPosition, totalCount))
|
|
let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion
|
|
return max(0, min(adjusted, max(0, totalCount - 1)))
|
|
}
|
|
}
|
|
|
|
enum SidebarAutoScrollDirection: Equatable {
|
|
case up
|
|
case down
|
|
}
|
|
|
|
struct SidebarAutoScrollPlan: Equatable {
|
|
let direction: SidebarAutoScrollDirection
|
|
let pointsPerTick: CGFloat
|
|
}
|
|
|
|
enum SidebarDragAutoScrollPlanner {
|
|
static let edgeInset: CGFloat = 44
|
|
static let minStep: CGFloat = 2
|
|
static let maxStep: CGFloat = 12
|
|
|
|
static func plan(
|
|
distanceToTop: CGFloat,
|
|
distanceToBottom: CGFloat,
|
|
edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset,
|
|
minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep,
|
|
maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep
|
|
) -> SidebarAutoScrollPlan? {
|
|
guard edgeInset > 0, maxStep >= minStep else { return nil }
|
|
if distanceToTop <= edgeInset {
|
|
let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset))
|
|
let step = minStep + ((maxStep - minStep) * normalized)
|
|
return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step)
|
|
}
|
|
if distanceToBottom <= edgeInset {
|
|
let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset))
|
|
let step = minStep + ((maxStep - minStep) * normalized)
|
|
return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SidebarDragAutoScrollController: ObservableObject {
|
|
private weak var scrollView: NSScrollView?
|
|
private var timer: Timer?
|
|
private var activePlan: SidebarAutoScrollPlan?
|
|
|
|
func attach(scrollView: NSScrollView?) {
|
|
self.scrollView = scrollView
|
|
}
|
|
|
|
func updateFromDragLocation() {
|
|
guard let scrollView else {
|
|
stop()
|
|
return
|
|
}
|
|
guard let plan = plan(for: scrollView) else {
|
|
stop()
|
|
return
|
|
}
|
|
activePlan = plan
|
|
startTimerIfNeeded()
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
activePlan = nil
|
|
}
|
|
|
|
private func startTimerIfNeeded() {
|
|
guard timer == nil else { return }
|
|
let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.tick()
|
|
}
|
|
}
|
|
self.timer = timer
|
|
RunLoop.main.add(timer, forMode: .eventTracking)
|
|
}
|
|
|
|
private func tick() {
|
|
guard NSEvent.pressedMouseButtons != 0 else {
|
|
stop()
|
|
return
|
|
}
|
|
guard let scrollView else {
|
|
stop()
|
|
return
|
|
}
|
|
|
|
// AppKit drag/drop autoscroll guidance recommends autoscroll(with:)
|
|
// when periodic drag updates are available; use it first.
|
|
if applyNativeAutoscroll(to: scrollView) {
|
|
activePlan = plan(for: scrollView)
|
|
if activePlan == nil {
|
|
stop()
|
|
}
|
|
return
|
|
}
|
|
|
|
activePlan = self.plan(for: scrollView)
|
|
guard let plan = activePlan else {
|
|
stop()
|
|
return
|
|
}
|
|
_ = apply(plan: plan, to: scrollView)
|
|
}
|
|
|
|
private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool {
|
|
guard let event = NSApp.currentEvent else { return false }
|
|
switch event.type {
|
|
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
|
break
|
|
default:
|
|
return false
|
|
}
|
|
|
|
let clipView = scrollView.contentView
|
|
let didScroll = clipView.autoscroll(with: event)
|
|
if didScroll {
|
|
scrollView.reflectScrolledClipView(clipView)
|
|
}
|
|
return didScroll
|
|
}
|
|
|
|
private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) {
|
|
if isFlipped {
|
|
return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y)
|
|
}
|
|
return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y)
|
|
}
|
|
|
|
private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? {
|
|
let viewportHeight = clipView.bounds.height
|
|
guard viewportHeight > 0 else { return nil }
|
|
|
|
let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped)
|
|
return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom)
|
|
}
|
|
|
|
private func mousePoint(in clipView: NSClipView) -> CGPoint {
|
|
let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero
|
|
return clipView.convert(mouseInWindow, from: nil)
|
|
}
|
|
|
|
private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
|
let clipView = scrollView.contentView
|
|
let mouse = mousePoint(in: clipView)
|
|
return planForMousePoint(mouse, in: clipView)
|
|
}
|
|
|
|
private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
|
currentPlan(for: scrollView)
|
|
}
|
|
|
|
private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool {
|
|
guard let documentView = scrollView.documentView else { return false }
|
|
let clipView = scrollView.contentView
|
|
let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height)
|
|
guard maxOriginY > 0 else { return false }
|
|
|
|
let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1
|
|
let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1
|
|
let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick
|
|
let currentY = clipView.bounds.origin.y
|
|
let targetY = min(max(currentY + delta, 0), maxOriginY)
|
|
guard abs(targetY - currentY) > 0.01 else { return false }
|
|
|
|
clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY))
|
|
scrollView.reflectScrolledClipView(clipView)
|
|
return true
|
|
}
|
|
}
|
|
|
|
private enum SidebarTabDragPayload {
|
|
static let typeIdentifier = UTType.plainText.identifier
|
|
private static let prefix = "cmux.sidebar-tab."
|
|
|
|
static func provider(for tabId: UUID) -> NSItemProvider {
|
|
NSItemProvider(object: "\(prefix)\(tabId.uuidString)" as NSString)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil
|
|
}
|
|
|
|
func dropEntered(info: DropInfo) {
|
|
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()
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
private func setupImageView() {
|
|
imageView = NSImageView()
|
|
imageView.imageScaling = .scaleProportionallyUpOrDown
|
|
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)
|
|
])
|
|
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) {
|
|
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 += 16
|
|
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)))
|
|
)
|
|
}
|
|
}
|