cmux/Sources/ContentView.swift
Lawrence Chen cc71e5797e
Add sidebar blur effect with withinWindow blending (#9)
* Add sidebar blur effect with withinWindow blending

- Add NSVisualEffectView-based blur backdrop for sidebar
- Support withinWindow blending mode to blur terminal content behind sidebar
- Auto-switch to overlay layout when withinWindow mode is selected
- Add sidebar debug panel with material, blending, tint, and opacity controls
- Add preset options (HUD Glass, Popover Glass, etc.)
- Default to HUD Glass preset with withinWindow blur

* Simplify tab close button visibility and remove hover background

Show close button only on hover instead of when active/multi-selected.
Remove the hover background color from tabs for cleaner appearance.

* Add config reload support with notification system

- Add reloadConfiguration() methods for app-wide and per-surface reload
- Handle GHOSTTY_ACTION_RELOAD_CONFIG action from Ghostty
- Add ghosttyConfigDidReload notification for views to react
- TerminalSplitTreeView reloads GhosttyConfig on notification
- Add openConfigurationInTextEdit() helper
- Fix activeMainWindow() to correctly find main window

* Add custom tab titles and pinned tabs support

- Add customTitle and isPinned properties to Tab
- Separate process title from custom title with applyProcessTitle()
- Add setCustomTitle()/clearCustomTitle() for user-defined tab names
- Add togglePin()/setPinned() with automatic reordering
- Pinned tabs stay at the top, new tabs insert after pinned section
- moveTabToTop/moveTabsToTop respect pinned tab ordering

* Add --panel option to new-split command

- CLI: Parse --panel <id|index> option for new-split
- Controller: Resolve panel argument to split specific surface
- Return new panel UUID on successful split creation

* Fix notifications popover positioning with layout-aware anchor

- Use AnchorNSView with layout callback for reliable positioning
- Force layout before showing popover to ensure current geometry
- Convert anchor bounds to window content view coordinates
- Add fallback positioning near top-left when anchor unavailable
- Fix button hit testing with explicit frame and contentShape

* Improve app termination in reload script

Use osascript to gracefully quit by bundle ID before pkill fallback.
Add more robust pkill patterns to catch instances from any DerivedData path.

* Add sidebar blur effect with live-adjustable glass settings

- Add WindowGlassEffect for window-level NSGlassEffectView (macOS 26+)
- Add SidebarBackdrop with configurable material, blend mode, tint, and opacity
- Add Sidebar Debug panel (Debug menu) for live adjustment of sidebar appearance
- Add Background Debug panel for window glass tint settings
- Support both behindWindow and withinWindow blur modes
- Live tint updates without requiring window reload

* Align titlebar text to left edge of content area
2026-02-04 03:04:45 -08:00

1354 lines
50 KiB
Swift

import AppKit
import SwiftUI
import ObjectiveC
/// 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
// Try NSGlassEffectView first (macOS 26 Tahoe+)
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type {
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 {
// Fallback to NSVisualEffectView
glassView = NSVisualEffectView(frame: bounds)
glassView.blendingMode = .behindWindow
glassView.material = .hudWindow
glassView.state = .active
glassView.wantsLayer = true
}
glassView.autoresizingMask = [.width, .height]
// Make glass view the new contentView, add original content on top
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)
// Pin to all edges
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)
])
// Add tint overlay between glass and content (for fallback)
if tintColor != nil, NSClassFromString("NSGlassEffectView") == nil {
let tintOverlay = NSView(frame: bounds)
tintOverlay.autoresizingMask = [.width, .height]
tintOverlay.wantsLayer = true
tintOverlay.layer?.backgroundColor = tintColor!.cgColor
glassView.addSubview(tintOverlay, positioned: .below, relativeTo: originalContentView)
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
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@EnvironmentObject var sidebarState: SidebarState
@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 sidebarSelection: SidebarSelection = .tabs
@State private var selectedTabIds: Set<UUID> = []
@State private var lastSidebarSelectionIndex: Int? = nil
@State private var titlebarText: String = ""
private var sidebarView: some View {
VerticalTabsSidebar(
selection: $sidebarSelection,
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 titlebar
private let titlebarPadding: CGFloat = 28
private var terminalContent: some View {
ZStack {
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
}
}
.opacity(sidebarSelection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelection == .tabs)
NotificationsPage(selection: $sidebarSelection)
.opacity(sidebarSelection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelection == .notifications)
}
.padding(.top, titlebarPadding)
.overlay(alignment: .top) {
// Titlebar with background - only over terminal content, not sidebar
customTitlebar
.background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor))
}
}
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
// Background glass settings
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
private var customTitlebar: some View {
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(.secondary)
.lineLimit(1)
Spacer()
}
.frame(height: 28)
.frame(maxWidth: .infinity)
.padding(.top, 2)
.padding(.leading, 12)
.padding(.trailing, 8)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
NSApp.keyWindow?.zoom(nil)
}
}
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 surface's directory if available
if let focusedSurfaceId = tab.focusedSurfaceId,
let surfaceDir = tab.surfaceDirectories[focusedSurfaceId] {
let trimmed = surfaceDir.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
sidebarSelection = .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("cmux.main")
window.titlebarAppearsTransparent = true
window.styleMask.insert(.fullSizeContentView)
// For behindWindow blur to work, window must be non-opaque with transparent content view
if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled {
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)
})
}
private func addTab() {
tabManager.addTab()
sidebarSelection = .tabs
}
private func updateWindowGlassTint() {
// Find main window by identifier (keyWindow might be the debug panel)
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) else { return }
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.updateTint(to: window, color: tintColor)
}
}
struct VerticalTabsSidebar: View {
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
/// Space at top of sidebar for traffic light buttons
private let trafficLightPadding: CGFloat = 28
var body: some View {
GeometryReader { proxy in
ScrollView {
VStack(spacing: 0) {
// Space for traffic lights
Spacer()
.frame(height: trafficLightPadding)
LazyVStack(spacing: 2) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
TabItemView(
tab: tab,
index: index,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
}
}
.padding(.vertical, 8)
SidebarEmptyArea(
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minHeight: proxy.size.height, alignment: .top)
}
.background(Color.clear)
.modifier(ClearScrollBackground())
.accessibilityIdentifier("Sidebar")
}
.ignoresSafeArea()
.background(SidebarBackdrop().ignoresSafeArea())
}
}
private struct SidebarFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SidebarEmptyArea: View {
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
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
}
}
}
struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@ObservedObject var tab: Tab
let index: Int
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
@State private var isHovering = false
var isActive: Bool {
tabManager.selectedTabId == tab.id
}
var isMultiSelected: Bool {
selectedTabIds.contains(tab.id)
}
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))
.foregroundColor(isActive ? .white : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Button(action: { tabManager.closeTab(tab) }) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
}
.buttonStyle(.plain)
.frame(width: 16, height: 16)
.opacity((isHovering && tabManager.tabs.count > 1) ? 1 : 0)
.allowsHitTesting(isHovering && tabManager.tabs.count > 1)
}
if let subtitle = latestNotificationText {
Text(subtitle)
.font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
.lineLimit(2)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
}
if let directories = directorySummary {
Text(directories)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.padding(.horizontal, 6)
.contentShape(Rectangle())
.onTapGesture {
updateSelection()
}
.onHover { hovering in
isHovering = hovering
}
.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("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 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.closeTab(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 directorySummary: String? {
guard let root = tab.splitTree.root else { return nil }
let surfaces = root.leaves()
guard !surfaces.isEmpty else { return nil }
let home = FileManager.default.homeDirectoryForCurrentUser.path
var seen: Set<String> = []
var entries: [String] = []
for surface in surfaces {
let directory = tab.surfaceDirectories[surface.id] ?? 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 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)
}
}
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
}
}
}
private struct SidebarBackdrop: View {
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.54
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#101010"
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 0.79
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)))
)
}
}