2561 lines
96 KiB
Swift
2561 lines
96 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()
|
|
}
|
|
}
|
|
|
|
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 DEBUG
|
|
dlog("sidebar.resizeDragStart")
|
|
#endif
|
|
if !isResizerHovering {
|
|
NSCursor.resizeLeftRight.push()
|
|
isResizerHovering = true
|
|
}
|
|
}
|
|
let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
|
|
withTransaction(Transaction(animation: nil)) {
|
|
sidebarWidth = nextWidth
|
|
}
|
|
}
|
|
.onEnded { _ in
|
|
if isResizerDragging {
|
|
isResizerDragging = false
|
|
if !isResizerHovering {
|
|
NSCursor.pop()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
|
|
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
|
|
@State private var titlebarPadding: CGFloat = 32
|
|
|
|
private var terminalContent: some View {
|
|
ZStack {
|
|
ZStack {
|
|
ForEach(tabManager.tabs) { tab in
|
|
let isActive = tabManager.selectedTabId == tab.id
|
|
WorkspaceContentView(workspace: tab, isTabActive: isActive)
|
|
.opacity(isActive ? 1 : 0)
|
|
.allowsHitTesting(isActive)
|
|
}
|
|
}
|
|
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
|
|
|
|
NotificationsPage(selection: $sidebarSelectionState.selection)
|
|
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
|
|
}
|
|
.padding(.top, titlebarPadding)
|
|
.overlay(alignment: .top) {
|
|
// Titlebar overlay is only over terminal content, not the sidebar.
|
|
customTitlebar
|
|
}
|
|
}
|
|
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
|
|
|
// Background glass settings
|
|
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
|
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
|
|
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
|
|
|
@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)
|
|
}
|
|
.overlay(alignment: .top) {
|
|
// Double-click the sidebar title-bar area to zoom the
|
|
// window, matching the panel top-bar behaviour.
|
|
DoubleClickZoomView()
|
|
.frame(height: trafficLightPadding)
|
|
}
|
|
.background(Color.clear)
|
|
.modifier(ClearScrollBackground())
|
|
}
|
|
#if DEBUG
|
|
SidebarDevFooter(updateViewModel: updateViewModel)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#else
|
|
UpdatePill(model: updateViewModel)
|
|
.padding(.horizontal, 10)
|
|
.padding(.bottom, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
#endif
|
|
}
|
|
.accessibilityIdentifier("Sidebar")
|
|
.ignoresSafeArea()
|
|
.background(SidebarBackdrop().ignoresSafeArea())
|
|
.onAppear {
|
|
commandKeyMonitor.start()
|
|
draggedTabId = nil
|
|
dropIndicator = nil
|
|
}
|
|
.onDisappear {
|
|
commandKeyMonitor.stop()
|
|
dragAutoScrollController.stop()
|
|
draggedTabId = nil
|
|
dropIndicator = nil
|
|
}
|
|
.onChange(of: draggedTabId) { newDraggedTabId in
|
|
guard newDraggedTabId == nil else { return }
|
|
dragAutoScrollController.stop()
|
|
dropIndicator = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SidebarCommandHintPolicy {
|
|
static let intentionalHoldDelay: TimeInterval = 0.30
|
|
|
|
static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool {
|
|
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
|
|
}
|
|
}
|
|
|
|
enum ShortcutHintDebugSettings {
|
|
static let sidebarHintXKey = "shortcutHintSidebarXOffset"
|
|
static let sidebarHintYKey = "shortcutHintSidebarYOffset"
|
|
static let titlebarHintXKey = "shortcutHintTitlebarXOffset"
|
|
static let titlebarHintYKey = "shortcutHintTitlebarYOffset"
|
|
static let paneHintXKey = "shortcutHintPaneTabXOffset"
|
|
static let paneHintYKey = "shortcutHintPaneTabYOffset"
|
|
static let alwaysShowHintsKey = "shortcutHintAlwaysShow"
|
|
|
|
static let defaultSidebarHintX = 0.0
|
|
static let defaultSidebarHintY = 0.0
|
|
static let defaultTitlebarHintX = 4.0
|
|
static let defaultTitlebarHintY = 0.0
|
|
static let defaultPaneHintX = 0.0
|
|
static let defaultPaneHintY = 0.0
|
|
static let defaultAlwaysShowHints = false
|
|
|
|
static let offsetRange: ClosedRange<Double> = -20...20
|
|
|
|
static func clamped(_ value: Double) -> Double {
|
|
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SidebarCommandKeyMonitor: ObservableObject {
|
|
@Published private(set) var isCommandPressed = false
|
|
|
|
private var flagsMonitor: Any?
|
|
private var keyDownMonitor: Any?
|
|
private var resignObserver: NSObjectProtocol?
|
|
private var pendingShowWorkItem: DispatchWorkItem?
|
|
|
|
func start() {
|
|
guard flagsMonitor == nil else {
|
|
update(from: NSEvent.modifierFlags)
|
|
return
|
|
}
|
|
|
|
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
|
self?.update(from: event.modifierFlags)
|
|
return event
|
|
}
|
|
|
|
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
self?.cancelPendingHintShow(resetVisible: true)
|
|
return event
|
|
}
|
|
|
|
resignObserver = NotificationCenter.default.addObserver(
|
|
forName: NSApplication.didResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.cancelPendingHintShow(resetVisible: true)
|
|
}
|
|
}
|
|
|
|
update(from: NSEvent.modifierFlags)
|
|
}
|
|
|
|
func stop() {
|
|
if let flagsMonitor {
|
|
NSEvent.removeMonitor(flagsMonitor)
|
|
self.flagsMonitor = nil
|
|
}
|
|
if let keyDownMonitor {
|
|
NSEvent.removeMonitor(keyDownMonitor)
|
|
self.keyDownMonitor = nil
|
|
}
|
|
if let resignObserver {
|
|
NotificationCenter.default.removeObserver(resignObserver)
|
|
self.resignObserver = nil
|
|
}
|
|
cancelPendingHintShow(resetVisible: true)
|
|
}
|
|
|
|
private func update(from modifierFlags: NSEvent.ModifierFlags) {
|
|
guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else {
|
|
cancelPendingHintShow(resetVisible: true)
|
|
return
|
|
}
|
|
|
|
queueHintShow()
|
|
}
|
|
|
|
private func queueHintShow() {
|
|
guard !isCommandPressed else { return }
|
|
guard pendingShowWorkItem == nil else { return }
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
self.pendingShowWorkItem = nil
|
|
guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return }
|
|
self.isCommandPressed = true
|
|
}
|
|
|
|
pendingShowWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
|
|
}
|
|
|
|
private func cancelPendingHintShow(resetVisible: Bool) {
|
|
pendingShowWorkItem?.cancel()
|
|
pendingShowWorkItem = nil
|
|
if resetVisible {
|
|
isCommandPressed = false
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private struct SidebarDevFooter: View {
|
|
@ObservedObject var updateViewModel: UpdateViewModel
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
UpdatePill(model: updateViewModel)
|
|
Text("THIS IS A DEV BUILD")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.red)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.bottom, 10)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private struct SidebarTopScrim: View {
|
|
let height: CGFloat
|
|
|
|
var body: some View {
|
|
SidebarTopBlurEffect()
|
|
.frame(height: height)
|
|
.mask(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.black.opacity(0.95),
|
|
Color.black.opacity(0.75),
|
|
Color.black.opacity(0.35),
|
|
Color.clear
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct SidebarTopBlurEffect: NSViewRepresentable {
|
|
func makeNSView(context: Context) -> NSVisualEffectView {
|
|
let view = NSVisualEffectView()
|
|
view.blendingMode = .withinWindow
|
|
view.material = .underWindowBackground
|
|
view.state = .active
|
|
view.isEmphasized = false
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
|
|
}
|
|
|
|
private struct SidebarFramePreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGRect = .zero
|
|
|
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
value = nextValue()
|
|
}
|
|
}
|
|
|
|
private struct SidebarScrollViewResolver: NSViewRepresentable {
|
|
let onResolve: (NSScrollView?) -> Void
|
|
|
|
func makeNSView(context: Context) -> SidebarScrollViewResolverView {
|
|
let view = SidebarScrollViewResolverView()
|
|
view.onResolve = onResolve
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) {
|
|
nsView.onResolve = onResolve
|
|
nsView.resolveScrollView()
|
|
}
|
|
}
|
|
|
|
private final class SidebarScrollViewResolverView: NSView {
|
|
var onResolve: ((NSScrollView?) -> Void)?
|
|
|
|
override func viewDidMoveToSuperview() {
|
|
super.viewDidMoveToSuperview()
|
|
resolveScrollView()
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
resolveScrollView()
|
|
}
|
|
|
|
func resolveScrollView() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
onResolve?(self.enclosingScrollView)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SidebarEmptyArea: View {
|
|
@EnvironmentObject var tabManager: TabManager
|
|
let rowSpacing: CGFloat
|
|
@Binding var selection: SidebarSelection
|
|
@Binding var selectedTabIds: Set<UUID>
|
|
@Binding var lastSidebarSelectionIndex: Int?
|
|
let dragAutoScrollController: SidebarDragAutoScrollController
|
|
@Binding var draggedTabId: UUID?
|
|
@Binding var dropIndicator: SidebarDropIndicator?
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onTapGesture(count: 2) {
|
|
tabManager.addTab()
|
|
if let selectedId = tabManager.selectedTabId {
|
|
selectedTabIds = [selectedId]
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
|
}
|
|
selection = .tabs
|
|
}
|
|
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
|
|
targetTabId: nil,
|
|
tabManager: tabManager,
|
|
draggedTabId: $draggedTabId,
|
|
selectedTabIds: $selectedTabIds,
|
|
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
|
targetRowHeight: nil,
|
|
dragAutoScrollController: dragAutoScrollController,
|
|
dropIndicator: $dropIndicator
|
|
))
|
|
.overlay(alignment: .top) {
|
|
if shouldShowTopDropIndicator {
|
|
Rectangle()
|
|
.fill(Color.accentColor)
|
|
.frame(height: 2)
|
|
.padding(.horizontal, 8)
|
|
.offset(y: -(rowSpacing / 2))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var shouldShowTopDropIndicator: Bool {
|
|
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
|
|
if indicator.tabId == nil {
|
|
return true
|
|
}
|
|
guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false }
|
|
return indicator.tabId == lastTabId
|
|
}
|
|
}
|
|
|
|
private struct TabItemView: View {
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@ObservedObject var tab: Tab
|
|
let index: Int
|
|
let rowSpacing: CGFloat
|
|
@Binding var selection: SidebarSelection
|
|
@Binding var selectedTabIds: Set<UUID>
|
|
@Binding var lastSidebarSelectionIndex: Int?
|
|
let showsCommandShortcutHints: Bool
|
|
let dragAutoScrollController: SidebarDragAutoScrollController
|
|
@Binding var draggedTabId: UUID?
|
|
@Binding var dropIndicator: SidebarDropIndicator?
|
|
@State private var isHovering = false
|
|
@State private var rowHeight: CGFloat = 1
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
|
|
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
|
|
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
|
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
|
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
|
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
|
|
|
|
var isActive: Bool {
|
|
tabManager.selectedTabId == tab.id
|
|
}
|
|
|
|
var isMultiSelected: Bool {
|
|
selectedTabIds.contains(tab.id)
|
|
}
|
|
|
|
private var isBeingDragged: Bool {
|
|
draggedTabId == tab.id
|
|
}
|
|
|
|
private var workspaceShortcutDigit: Int? {
|
|
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
|
|
}
|
|
|
|
private var showCloseButton: Bool {
|
|
isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints)
|
|
}
|
|
|
|
private var workspaceShortcutLabel: String? {
|
|
guard let workspaceShortcutDigit else { return nil }
|
|
return "⌘\(workspaceShortcutDigit)"
|
|
}
|
|
|
|
private var showsWorkspaceShortcutHint: Bool {
|
|
(showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
|
|
}
|
|
|
|
private var workspaceHintSlotWidth: CGFloat {
|
|
guard let label = workspaceShortcutLabel else { return 28 }
|
|
let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + 2
|
|
return max(28, workspaceHintWidth(for: label) + positiveDebugInset)
|
|
}
|
|
|
|
private func workspaceHintWidth(for label: String) -> CGFloat {
|
|
let font = NSFont.systemFont(ofSize: 10, weight: .semibold)
|
|
let textWidth = (label as NSString).size(withAttributes: [.font: font]).width
|
|
return ceil(textWidth) + 12
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
|
|
if unreadCount > 0 {
|
|
ZStack {
|
|
Circle()
|
|
.fill(isActive ? Color.white.opacity(0.25) : Color.accentColor)
|
|
Text("\(unreadCount)")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 16, height: 16)
|
|
}
|
|
|
|
if tab.isPinned {
|
|
Image(systemName: "pin.fill")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
}
|
|
|
|
Text(tab.title)
|
|
.font(.system(size: 12.5, weight: .semibold))
|
|
.foregroundColor(isActive ? .white : .primary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
|
|
Spacer()
|
|
|
|
ZStack(alignment: .trailing) {
|
|
Button(action: {
|
|
#if DEBUG
|
|
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button")
|
|
#endif
|
|
tabManager.closeWorkspaceWithConfirmation(tab)
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))")
|
|
.frame(width: 16, height: 16, alignment: .center)
|
|
.opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0)
|
|
.allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint)
|
|
|
|
if showsWorkspaceShortcutHint, let workspaceShortcutLabel {
|
|
Text(workspaceShortcutLabel)
|
|
.lineLimit(1)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.monospacedDigit()
|
|
.foregroundColor(isActive ? .white : .primary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9))
|
|
.offset(
|
|
x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset),
|
|
y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
|
|
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
|
|
}
|
|
|
|
if let subtitle = latestNotificationText {
|
|
Text(subtitle)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
if sidebarShowStatusPills, !tab.statusEntries.isEmpty {
|
|
SidebarStatusPillsRow(
|
|
entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
|
|
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
|
return lhs.key < rhs.key
|
|
}),
|
|
isActive: isActive,
|
|
onFocus: { updateSelection() }
|
|
)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Latest log entry
|
|
if sidebarShowLog, let latestLog = tab.logEntries.last {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: logLevelIcon(latestLog.level))
|
|
.font(.system(size: 8))
|
|
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive))
|
|
Text(latestLog.message)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Progress bar
|
|
if sidebarShowProgress, let progress = tab.progress {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2))
|
|
Capsule()
|
|
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor)
|
|
.frame(width: max(0, geo.size.width * CGFloat(progress.value)))
|
|
}
|
|
}
|
|
.frame(height: 3)
|
|
|
|
if let label = progress.label {
|
|
Text(label)
|
|
.font(.system(size: 9))
|
|
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
}
|
|
|
|
// Branch + directory + ports row
|
|
if let dirRow = branchDirectoryRow {
|
|
HStack(spacing: 3) {
|
|
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
|
|
Image(systemName: "arrow.triangle.branch")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
|
|
}
|
|
Text(dirRow)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
}
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
|
|
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(backgroundColor)
|
|
)
|
|
.padding(.horizontal, 6)
|
|
.background {
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
rowHeight = max(proxy.size.height, 1)
|
|
}
|
|
.onChange(of: proxy.size.height) { newHeight in
|
|
rowHeight = max(newHeight, 1)
|
|
}
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.opacity(isBeingDragged ? 0.6 : 1)
|
|
.overlay {
|
|
MiddleClickCapture {
|
|
#if DEBUG
|
|
dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick")
|
|
#endif
|
|
tabManager.closeWorkspaceWithConfirmation(tab)
|
|
}
|
|
}
|
|
.overlay(alignment: .top) {
|
|
if showsCenteredTopDropIndicator {
|
|
Rectangle()
|
|
.fill(Color.accentColor)
|
|
.frame(height: 2)
|
|
.padding(.horizontal, 8)
|
|
.offset(y: index == 0 ? 0 : -(rowSpacing / 2))
|
|
}
|
|
}
|
|
.onDrag {
|
|
draggedTabId = tab.id
|
|
dropIndicator = nil
|
|
return SidebarTabDragPayload.provider(for: tab.id)
|
|
}
|
|
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
|
|
targetTabId: tab.id,
|
|
tabManager: tabManager,
|
|
draggedTabId: $draggedTabId,
|
|
selectedTabIds: $selectedTabIds,
|
|
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
|
targetRowHeight: rowHeight,
|
|
dragAutoScrollController: dragAutoScrollController,
|
|
dropIndicator: $dropIndicator
|
|
))
|
|
.onTapGesture {
|
|
updateSelection()
|
|
}
|
|
.onHover { hovering in
|
|
isHovering = hovering
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(Text(accessibilityTitle))
|
|
.accessibilityHint(Text("Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions."))
|
|
.accessibilityAction(named: Text("Move Up")) {
|
|
moveBy(-1)
|
|
}
|
|
.accessibilityAction(named: Text("Move Down")) {
|
|
moveBy(1)
|
|
}
|
|
.contextMenu {
|
|
let targetIds = contextTargetIds()
|
|
let shouldPin = !tab.isPinned
|
|
let pinLabel = targetIds.count > 1
|
|
? (shouldPin ? "Pin Tabs" : "Unpin Tabs")
|
|
: (shouldPin ? "Pin Tab" : "Unpin Tab")
|
|
Button(pinLabel) {
|
|
for id in targetIds {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
|
tabManager.setPinned(tab, pinned: shouldPin)
|
|
}
|
|
}
|
|
syncSelectionAfterMutation()
|
|
}
|
|
|
|
Button("Rename Tab…") {
|
|
promptRename()
|
|
}
|
|
|
|
if tab.hasCustomTitle {
|
|
Button("Remove Custom Name") {
|
|
tabManager.clearCustomTitle(tabId: tab.id)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Move Up") {
|
|
moveBy(-1)
|
|
}
|
|
.disabled(index == 0)
|
|
|
|
Button("Move Down") {
|
|
moveBy(1)
|
|
}
|
|
.disabled(index >= tabManager.tabs.count - 1)
|
|
|
|
Divider()
|
|
|
|
Button("Close Tabs") {
|
|
closeTabs(targetIds, allowPinned: true)
|
|
}
|
|
.disabled(targetIds.isEmpty)
|
|
|
|
Button("Close Others") {
|
|
closeOtherTabs(targetIds)
|
|
}
|
|
.disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count)
|
|
|
|
Button("Close Tabs Below") {
|
|
closeTabsBelow(tabId: tab.id)
|
|
}
|
|
.disabled(index >= tabManager.tabs.count - 1)
|
|
|
|
Button("Close Tabs Above") {
|
|
closeTabsAbove(tabId: tab.id)
|
|
}
|
|
.disabled(index == 0)
|
|
|
|
Divider()
|
|
|
|
Button("Move to Top") {
|
|
tabManager.moveTabsToTop(Set(targetIds))
|
|
syncSelectionAfterMutation()
|
|
}
|
|
.disabled(targetIds.isEmpty)
|
|
|
|
Divider()
|
|
|
|
Button("Mark as Read") {
|
|
markTabsRead(targetIds)
|
|
}
|
|
.disabled(!hasUnreadNotifications(in: targetIds))
|
|
|
|
Button("Mark as Unread") {
|
|
markTabsUnread(targetIds)
|
|
}
|
|
.disabled(!hasReadNotifications(in: targetIds))
|
|
}
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
if isActive {
|
|
return Color.accentColor
|
|
}
|
|
if isMultiSelected {
|
|
return Color.accentColor.opacity(0.25)
|
|
}
|
|
return Color.clear
|
|
}
|
|
|
|
private var showsCenteredTopDropIndicator: Bool {
|
|
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
|
|
if indicator.tabId == tab.id && indicator.edge == .top {
|
|
return true
|
|
}
|
|
|
|
guard indicator.edge == .bottom,
|
|
let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }),
|
|
currentIndex > 0
|
|
else {
|
|
return false
|
|
}
|
|
return tabManager.tabs[currentIndex - 1].id == indicator.tabId
|
|
}
|
|
|
|
private var accessibilityTitle: String {
|
|
"\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)"
|
|
}
|
|
|
|
private func moveBy(_ delta: Int) {
|
|
let targetIndex = index + delta
|
|
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
|
|
guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return }
|
|
selectedTabIds = [tab.id]
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
|
|
tabManager.selectTab(tab)
|
|
selection = .tabs
|
|
}
|
|
|
|
private func updateSelection() {
|
|
#if DEBUG
|
|
let mods = NSEvent.modifierFlags
|
|
var modStr = ""
|
|
if mods.contains(.command) { modStr += "cmd " }
|
|
if mods.contains(.shift) { modStr += "shift " }
|
|
if mods.contains(.option) { modStr += "opt " }
|
|
if mods.contains(.control) { modStr += "ctrl " }
|
|
dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))")
|
|
#endif
|
|
let modifiers = NSEvent.modifierFlags
|
|
let isCommand = modifiers.contains(.command)
|
|
let isShift = modifiers.contains(.shift)
|
|
|
|
if isShift, let lastIndex = lastSidebarSelectionIndex {
|
|
let lower = min(lastIndex, index)
|
|
let upper = max(lastIndex, index)
|
|
let rangeIds = tabManager.tabs[lower...upper].map { $0.id }
|
|
if isCommand {
|
|
selectedTabIds.formUnion(rangeIds)
|
|
} else {
|
|
selectedTabIds = Set(rangeIds)
|
|
}
|
|
} else if isCommand {
|
|
if selectedTabIds.contains(tab.id) {
|
|
selectedTabIds.remove(tab.id)
|
|
} else {
|
|
selectedTabIds.insert(tab.id)
|
|
}
|
|
} else {
|
|
selectedTabIds = [tab.id]
|
|
}
|
|
|
|
lastSidebarSelectionIndex = index
|
|
tabManager.selectTab(tab)
|
|
selection = .tabs
|
|
}
|
|
|
|
private func contextTargetIds() -> [UUID] {
|
|
let baseIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
|
|
return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil }
|
|
}
|
|
|
|
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
|
|
let idsToClose = targetIds.filter { id in
|
|
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
|
|
return allowPinned || !tab.isPinned
|
|
}
|
|
for id in idsToClose {
|
|
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
|
|
tabManager.closeWorkspaceWithConfirmation(tab)
|
|
}
|
|
}
|
|
selectedTabIds.subtract(idsToClose)
|
|
syncSelectionAfterMutation()
|
|
}
|
|
|
|
private func closeOtherTabs(_ targetIds: [UUID]) {
|
|
let keepIds = Set(targetIds)
|
|
let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func closeTabsBelow(tabId: UUID) {
|
|
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func closeTabsAbove(tabId: UUID) {
|
|
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id }
|
|
closeTabs(idsToClose, allowPinned: false)
|
|
}
|
|
|
|
private func markTabsRead(_ targetIds: [UUID]) {
|
|
for id in targetIds {
|
|
notificationStore.markRead(forTabId: id)
|
|
}
|
|
}
|
|
|
|
private func markTabsUnread(_ targetIds: [UUID]) {
|
|
for id in targetIds {
|
|
notificationStore.markUnread(forTabId: id)
|
|
}
|
|
}
|
|
|
|
private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool {
|
|
let targetSet = Set(targetIds)
|
|
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead }
|
|
}
|
|
|
|
private func hasReadNotifications(in targetIds: [UUID]) -> Bool {
|
|
let targetSet = Set(targetIds)
|
|
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead }
|
|
}
|
|
|
|
private func syncSelectionAfterMutation() {
|
|
let existingIds = Set(tabManager.tabs.map { $0.id })
|
|
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
|
selectedTabIds = [selectedId]
|
|
}
|
|
if let selectedId = tabManager.selectedTabId {
|
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
|
}
|
|
}
|
|
|
|
private var latestNotificationText: String? {
|
|
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
|
|
let text = notification.body.isEmpty ? notification.title : notification.body
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private var branchDirectoryRow: String? {
|
|
var parts: [String] = []
|
|
|
|
// Git branch (if enabled and available)
|
|
if sidebarShowGitBranch, let git = tab.gitBranch {
|
|
let dirty = git.isDirty ? "*" : ""
|
|
parts.append("\(git.branch)\(dirty)")
|
|
}
|
|
|
|
// Directory summary
|
|
if let dirs = directorySummaryText {
|
|
parts.append(dirs)
|
|
}
|
|
|
|
// Ports (if enabled and available)
|
|
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
|
|
let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",")
|
|
parts.append(portsStr)
|
|
}
|
|
|
|
let result = parts.joined(separator: " · ")
|
|
return result.isEmpty ? nil : result
|
|
}
|
|
|
|
private var directorySummaryText: String? {
|
|
guard !tab.panels.isEmpty else { return nil }
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
var seen: Set<String> = []
|
|
var entries: [String] = []
|
|
for panelId in tab.panels.keys {
|
|
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
|
|
let shortened = shortenPath(directory, home: home)
|
|
guard !shortened.isEmpty else { continue }
|
|
if seen.insert(shortened).inserted {
|
|
entries.append(shortened)
|
|
}
|
|
}
|
|
return entries.isEmpty ? nil : entries.joined(separator: " | ")
|
|
}
|
|
|
|
private func logLevelIcon(_ level: SidebarLogLevel) -> String {
|
|
switch level {
|
|
case .info: return "circle.fill"
|
|
case .progress: return "arrowtriangle.right.fill"
|
|
case .success: return "checkmark.circle.fill"
|
|
case .warning: return "exclamationmark.triangle.fill"
|
|
case .error: return "xmark.circle.fill"
|
|
}
|
|
}
|
|
|
|
private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color {
|
|
if isActive {
|
|
switch level {
|
|
case .info: return .white.opacity(0.5)
|
|
case .progress: return .white.opacity(0.8)
|
|
case .success: return .white.opacity(0.9)
|
|
case .warning: return .white.opacity(0.9)
|
|
case .error: return .white.opacity(0.9)
|
|
}
|
|
}
|
|
switch level {
|
|
case .info: return .secondary
|
|
case .progress: return .blue
|
|
case .success: return .green
|
|
case .warning: return .orange
|
|
case .error: return .red
|
|
}
|
|
}
|
|
|
|
private func shortenPath(_ path: String, home: String) -> String {
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return path }
|
|
if trimmed == home {
|
|
return "~"
|
|
}
|
|
if trimmed.hasPrefix(home + "/") {
|
|
return "~" + trimmed.dropFirst(home.count)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
private func promptRename() {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Tab"
|
|
alert.informativeText = "Enter a custom name for this tab."
|
|
let input = NSTextField(string: tab.customTitle ?? tab.title)
|
|
input.placeholderString = "Tab name"
|
|
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "Cancel")
|
|
let alertWindow = alert.window
|
|
alertWindow.initialFirstResponder = input
|
|
DispatchQueue.main.async {
|
|
alertWindow.makeFirstResponder(input)
|
|
input.selectText(nil)
|
|
}
|
|
let response = alert.runModal()
|
|
guard response == .alertFirstButtonReturn else { return }
|
|
tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue)
|
|
}
|
|
}
|
|
|
|
private struct SidebarStatusPillsRow: View {
|
|
let entries: [SidebarStatusEntry]
|
|
let isActive: Bool
|
|
let onFocus: () -> Void
|
|
|
|
@State private var isExpanded: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(statusText)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
|
|
.lineLimit(isExpanded ? nil : 3)
|
|
.truncationMode(.tail)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onFocus()
|
|
guard shouldShowToggle else { return }
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isExpanded.toggle()
|
|
}
|
|
}
|
|
|
|
if shouldShowToggle {
|
|
Button(isExpanded ? "Show less" : "Show more") {
|
|
onFocus()
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isExpanded.toggle()
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.help(statusText)
|
|
}
|
|
|
|
private var statusText: String {
|
|
entries
|
|
.map { entry in
|
|
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty { return value }
|
|
return entry.key
|
|
}
|
|
.joined(separator: "\n")
|
|
}
|
|
|
|
private var shouldShowToggle: Bool {
|
|
entries.count > 1 || statusText.count > 120
|
|
}
|
|
}
|
|
|
|
enum SidebarDropEdge {
|
|
case top
|
|
case bottom
|
|
}
|
|
|
|
struct SidebarDropIndicator {
|
|
let tabId: UUID?
|
|
let edge: SidebarDropEdge
|
|
}
|
|
|
|
enum SidebarDropPlanner {
|
|
static func indicator(
|
|
draggedTabId: UUID?,
|
|
targetTabId: UUID?,
|
|
tabIds: [UUID],
|
|
pointerY: CGFloat? = nil,
|
|
targetHeight: CGFloat? = nil
|
|
) -> SidebarDropIndicator? {
|
|
guard tabIds.count > 1, let draggedTabId else { return nil }
|
|
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
|
|
|
let insertionPosition: Int
|
|
if let targetTabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
|
let edge: SidebarDropEdge
|
|
if let pointerY, let targetHeight {
|
|
edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight)
|
|
} else {
|
|
edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
|
}
|
|
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
|
} else {
|
|
insertionPosition = tabIds.count
|
|
}
|
|
|
|
let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
|
guard targetIndex != fromIndex else { return nil }
|
|
return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds)
|
|
}
|
|
|
|
static func targetIndex(
|
|
draggedTabId: UUID,
|
|
targetTabId: UUID?,
|
|
indicator: SidebarDropIndicator?,
|
|
tabIds: [UUID]
|
|
) -> Int? {
|
|
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
|
|
|
|
let insertionPosition: Int
|
|
if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) {
|
|
insertionPosition = indicatorInsertion
|
|
} else if let targetTabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
|
|
let edge = (indicator?.tabId == targetTabId)
|
|
? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds))
|
|
: preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
|
|
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
|
|
} else {
|
|
insertionPosition = tabIds.count
|
|
}
|
|
|
|
return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
|
|
}
|
|
|
|
private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator {
|
|
let clampedInsertion = max(0, min(insertionPosition, tabIds.count))
|
|
if clampedInsertion >= tabIds.count {
|
|
return SidebarDropIndicator(tabId: nil, edge: .bottom)
|
|
}
|
|
return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top)
|
|
}
|
|
|
|
private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? {
|
|
if let tabId = indicator.tabId {
|
|
guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil }
|
|
return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex
|
|
}
|
|
return tabIds.count
|
|
}
|
|
|
|
private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge {
|
|
guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top }
|
|
return fromIndex < targetIndex ? .bottom : .top
|
|
}
|
|
|
|
static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge {
|
|
guard targetHeight > 0 else { return .top }
|
|
let clampedY = min(max(locationY, 0), targetHeight)
|
|
return clampedY < (targetHeight / 2) ? .top : .bottom
|
|
}
|
|
|
|
private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int {
|
|
let clampedInsertion = max(0, min(insertionPosition, totalCount))
|
|
let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion
|
|
return max(0, min(adjusted, max(0, totalCount - 1)))
|
|
}
|
|
}
|
|
|
|
enum SidebarAutoScrollDirection: Equatable {
|
|
case up
|
|
case down
|
|
}
|
|
|
|
struct SidebarAutoScrollPlan: Equatable {
|
|
let direction: SidebarAutoScrollDirection
|
|
let pointsPerTick: CGFloat
|
|
}
|
|
|
|
enum SidebarDragAutoScrollPlanner {
|
|
static let edgeInset: CGFloat = 44
|
|
static let minStep: CGFloat = 2
|
|
static let maxStep: CGFloat = 12
|
|
|
|
static func plan(
|
|
distanceToTop: CGFloat,
|
|
distanceToBottom: CGFloat,
|
|
edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset,
|
|
minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep,
|
|
maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep
|
|
) -> SidebarAutoScrollPlan? {
|
|
guard edgeInset > 0, maxStep >= minStep else { return nil }
|
|
if distanceToTop <= edgeInset {
|
|
let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset))
|
|
let step = minStep + ((maxStep - minStep) * normalized)
|
|
return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step)
|
|
}
|
|
if distanceToBottom <= edgeInset {
|
|
let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset))
|
|
let step = minStep + ((maxStep - minStep) * normalized)
|
|
return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SidebarDragAutoScrollController: ObservableObject {
|
|
private weak var scrollView: NSScrollView?
|
|
private var timer: Timer?
|
|
private var activePlan: SidebarAutoScrollPlan?
|
|
|
|
func attach(scrollView: NSScrollView?) {
|
|
self.scrollView = scrollView
|
|
}
|
|
|
|
func updateFromDragLocation() {
|
|
guard let scrollView else {
|
|
stop()
|
|
return
|
|
}
|
|
guard let plan = plan(for: scrollView) else {
|
|
stop()
|
|
return
|
|
}
|
|
activePlan = plan
|
|
startTimerIfNeeded()
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
activePlan = nil
|
|
}
|
|
|
|
private func startTimerIfNeeded() {
|
|
guard timer == nil else { return }
|
|
let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.tick()
|
|
}
|
|
}
|
|
self.timer = timer
|
|
RunLoop.main.add(timer, forMode: .eventTracking)
|
|
}
|
|
|
|
private func tick() {
|
|
guard NSEvent.pressedMouseButtons != 0 else {
|
|
stop()
|
|
return
|
|
}
|
|
guard let scrollView else {
|
|
stop()
|
|
return
|
|
}
|
|
|
|
// AppKit drag/drop autoscroll guidance recommends autoscroll(with:)
|
|
// when periodic drag updates are available; use it first.
|
|
if applyNativeAutoscroll(to: scrollView) {
|
|
activePlan = plan(for: scrollView)
|
|
if activePlan == nil {
|
|
stop()
|
|
}
|
|
return
|
|
}
|
|
|
|
activePlan = self.plan(for: scrollView)
|
|
guard let plan = activePlan else {
|
|
stop()
|
|
return
|
|
}
|
|
_ = apply(plan: plan, to: scrollView)
|
|
}
|
|
|
|
private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool {
|
|
guard let event = NSApp.currentEvent else { return false }
|
|
switch event.type {
|
|
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
|
break
|
|
default:
|
|
return false
|
|
}
|
|
|
|
let clipView = scrollView.contentView
|
|
let didScroll = clipView.autoscroll(with: event)
|
|
if didScroll {
|
|
scrollView.reflectScrolledClipView(clipView)
|
|
}
|
|
return didScroll
|
|
}
|
|
|
|
private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) {
|
|
if isFlipped {
|
|
return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y)
|
|
}
|
|
return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y)
|
|
}
|
|
|
|
private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? {
|
|
let viewportHeight = clipView.bounds.height
|
|
guard viewportHeight > 0 else { return nil }
|
|
|
|
let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped)
|
|
return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom)
|
|
}
|
|
|
|
private func mousePoint(in clipView: NSClipView) -> CGPoint {
|
|
let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero
|
|
return clipView.convert(mouseInWindow, from: nil)
|
|
}
|
|
|
|
private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
|
let clipView = scrollView.contentView
|
|
let mouse = mousePoint(in: clipView)
|
|
return planForMousePoint(mouse, in: clipView)
|
|
}
|
|
|
|
private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
|
|
currentPlan(for: scrollView)
|
|
}
|
|
|
|
private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool {
|
|
guard let documentView = scrollView.documentView else { return false }
|
|
let clipView = scrollView.contentView
|
|
let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height)
|
|
guard maxOriginY > 0 else { return false }
|
|
|
|
let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1
|
|
let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1
|
|
let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick
|
|
let currentY = clipView.bounds.origin.y
|
|
let targetY = min(max(currentY + delta, 0), maxOriginY)
|
|
guard abs(targetY - currentY) > 0.01 else { return false }
|
|
|
|
clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY))
|
|
scrollView.reflectScrolledClipView(clipView)
|
|
return true
|
|
}
|
|
}
|
|
|
|
private enum SidebarTabDragPayload {
|
|
static let typeIdentifier = 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()
|
|
}
|
|
#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")
|
|
}
|
|
|
|
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) {
|
|
#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 += 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)))
|
|
)
|
|
}
|
|
}
|