cmux/Sources/WorkspaceContentView.swift
Lawrence Chen d015ace094
Fix minimal mode tab bar disappearing in fullscreen (#2375)
Three issues caused the Bonsplit horizontal tab bar to be hidden
when entering fullscreen with minimal mode enabled:

1. ignoresSafeArea(.container, edges: .top) was applied unconditionally
   in minimal mode, pushing content behind the fullscreen menu bar area.
   Now gated on !isFullScreen.

2. effectiveTitlebarPadding returned -titlebarPadding in minimal mode
   regardless of fullscreen state. In fullscreen there is no native
   titlebar to compensate for, so the negative offset pushed content
   off the top of the screen. Now returns 0 in fullscreen.

3. Traffic light leading inset (80px) was applied in fullscreen minimal
   mode even though there are no traffic light buttons. Now gated on
   !isFullScreen, and syncTrafficLightInset is called on fullscreen
   enter/exit.

Closes https://github.com/manaflow-ai/cmux/issues/2317
Based on https://github.com/manaflow-ai/cmux/pull/2341

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
2026-03-30 16:41:05 -07:00

842 lines
32 KiB
Swift

import SwiftUI
import Foundation
import AppKit
import Bonsplit
enum TmuxOverlayExperimentTarget: String, CaseIterable, Codable, Sendable {
case surface
case bonsplitPane
case tmuxActivePane
var usesWorkspacePaneOverlay: Bool {
self == .bonsplitPane
}
var usesTmuxActivePaneOverlay: Bool {
self == .tmuxActivePane
}
}
struct TmuxOverlayExperimentSettings {
static let enabledKey = "tmuxOverlayExperimentEnabled"
static let targetKey = "tmuxOverlayExperimentTarget"
static let defaultEnabled = false
static let defaultTarget: TmuxOverlayExperimentTarget = .surface
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled
}
static func target(defaults: UserDefaults = .standard) -> TmuxOverlayExperimentTarget {
target(
enabled: isEnabled(defaults: defaults),
rawValue: defaults.string(forKey: targetKey)
)
}
static func target(enabled: Bool, rawValue: String?) -> TmuxOverlayExperimentTarget {
guard enabled else { return .surface }
guard let rawValue,
let target = TmuxOverlayExperimentTarget(rawValue: rawValue) else {
return defaultTarget
}
return target
}
}
private enum WorkspaceTitlebarInteractionMetrics {
// Keep in sync with Bonsplit's tab bar height so the monitor only covers
// the minimal-mode titlebar strip.
static let minimalModeTopStripHeight: CGFloat = 30
}
struct TmuxPaneLayoutPane: Codable, Equatable, Sendable {
let paneId: String
let left: Int
let top: Int
let width: Int
let height: Int
let isActive: Bool
}
struct TmuxPaneLayoutReport: Codable, Equatable, Sendable {
let panes: [TmuxPaneLayoutPane]
var activePane: TmuxPaneLayoutPane? {
panes.first(where: \.isActive) ?? panes.first
}
}
func tmuxActivePaneOverlayRect(
surfaceFrame: CGRect,
cellSize: CGSize,
pane: TmuxPaneLayoutPane
) -> CGRect? {
guard cellSize.width > 0,
cellSize.height > 0,
pane.width > 0,
pane.height > 0 else {
return nil
}
return CGRect(
x: surfaceFrame.origin.x + (CGFloat(pane.left) * cellSize.width),
y: surfaceFrame.origin.y + (CGFloat(pane.top) * cellSize.height),
width: CGFloat(pane.width) * cellSize.width,
height: CGFloat(pane.height) * cellSize.height
)
}
private extension PixelRect {
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
struct TmuxWorkspacePaneOverlayRenderState: Equatable {
let workspaceId: UUID
let unreadRects: [CGRect]
let flashRect: CGRect?
let flashToken: UInt64
let flashReason: WorkspaceAttentionFlashReason?
}
@MainActor
final class TmuxWorkspacePaneOverlayModel: ObservableObject {
@Published private(set) var unreadRects: [CGRect] = []
@Published private(set) var flashRect: CGRect?
@Published private(set) var flashStartedAt: Date?
@Published private(set) var flashReason: WorkspaceAttentionFlashReason?
private var lastWorkspaceId: UUID?
private var lastFlashToken: UInt64?
func apply(
_ state: TmuxWorkspacePaneOverlayRenderState,
now: () -> Date = Date.init
) {
unreadRects = state.unreadRects
flashRect = state.flashRect
flashReason = state.flashReason
let didChangeWorkspace = lastWorkspaceId != state.workspaceId
if didChangeWorkspace {
lastWorkspaceId = state.workspaceId
lastFlashToken = state.flashToken
flashStartedAt = nil
return
}
if let lastFlashToken,
state.flashToken != lastFlashToken,
state.flashRect != nil {
flashStartedAt = now()
}
self.lastFlashToken = state.flashToken
}
func clear() {
unreadRects = []
flashRect = nil
flashStartedAt = nil
flashReason = nil
lastWorkspaceId = nil
lastFlashToken = nil
}
}
struct TmuxWorkspacePaneOverlayView: View {
let unreadRects: [CGRect]
let flashRect: CGRect?
let flashStartedAt: Date?
let flashReason: WorkspaceAttentionFlashReason?
var body: some View {
TimelineView(.animation) { timeline in
Canvas { context, _ in
for rect in unreadRects {
drawUnreadRing(in: &context, rect: rect)
}
guard let flashRect,
let flashStartedAt else { return }
let elapsed = timeline.date.timeIntervalSince(flashStartedAt)
let opacity = FocusFlashPattern.opacity(at: elapsed)
guard opacity > 0.001 else { return }
drawFlashRing(
in: &context,
rect: flashRect,
opacity: opacity,
reason: flashReason ?? .notificationArrival
)
}
}
.allowsHitTesting(false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect) {
guard let path = ringPath(for: rect) else { return }
var glowContext = context
glowContext.addFilter(.shadow(color: Color.blue.opacity(0.35), radius: 3))
glowContext.stroke(
path,
with: .color(Color.blue),
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
)
}
private func drawFlashRing(
in context: inout GraphicsContext,
rect: CGRect,
opacity: Double,
reason: WorkspaceAttentionFlashReason
) {
guard let path = ringPath(for: rect) else { return }
let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason)
let strokeColor = Color(nsColor: presentation.accent.strokeColor)
var glowContext = context
glowContext.addFilter(
.shadow(
color: strokeColor.opacity(opacity * presentation.glowOpacity),
radius: presentation.glowRadius
)
)
glowContext.stroke(
path,
with: .color(strokeColor.opacity(opacity)),
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
)
}
private func ringPath(for rect: CGRect) -> Path? {
guard rect.width > PanelOverlayRingMetrics.inset * 2,
rect.height > PanelOverlayRingMetrics.inset * 2 else { return nil }
return Path(
roundedRect: PanelOverlayRingMetrics.pathRect(in: rect),
cornerRadius: PanelOverlayRingMetrics.cornerRadius
)
}
}
/// View that renders a Workspace's content using BonsplitView
struct WorkspaceContentView: View {
@ObservedObject var workspace: Workspace
let isWorkspaceVisible: Bool
let isWorkspaceInputActive: Bool
let isFullScreen: Bool
let workspacePortalPriority: Int
let onThemeRefreshRequest: ((
_ reason: String,
_ backgroundEventId: UInt64?,
_ backgroundSource: String?,
_ notificationPayloadHex: String?
) -> Void)?
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
@AppStorage(WorkspacePresentationModeSettings.modeKey)
private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var notificationStore: TerminalNotificationStore
private var isMinimalMode: Bool {
WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal
}
static func panelVisibleInUI(
isWorkspaceVisible: Bool,
isSelectedInPane: Bool,
isFocused: Bool
) -> Bool {
guard isWorkspaceVisible else { return false }
// During pane/tab reparenting, Bonsplit can transiently report selected=false
// for the currently focused panel. Keep focused content visible to avoid blank frames.
return isSelectedInPane || isFocused
}
var body: some View {
let appearance = PanelAppearance.fromConfig(config)
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
workspace.panels.count > 1
let usesWorkspacePaneOverlay = TmuxOverlayExperimentSettings.target().usesWorkspacePaneOverlay
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }()
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
// Finder file drops to the correct terminal panel.
let _ = {
workspace.bonsplitController.onFileDrop = { [weak workspace] urls, paneId in
guard let workspace else { return false }
// Find the focused panel in this pane and drop the files into it.
guard let tabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id,
let panelId = workspace.panelIdFromSurfaceId(tabId),
let panel = workspace.panels[panelId] as? TerminalPanel else { return false }
return panel.hostedView.handleDroppedURLs(urls)
}
}()
let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let showsNotificationRing = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
forTabId: workspace.id,
surfaceId: panel.id
),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
paneId: paneId,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: showsNotificationRing && !usesWorkspacePaneOverlay,
onFocus: {
// Keep bonsplit focus in sync with the AppKit first responder for the
// active workspace. This prevents divergence between the blue focused-tab
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
}
} emptyPane: { paneId in
// Empty pane content
EmptyPanelView(workspace: workspace, paneId: paneId)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
}
.internalOnlyTabDrag()
// Split zoom swaps Bonsplit between the full split tree and a single pane view.
// Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome
// cannot remain stacked above portal-hosted browser content.
.id(splitZoomRenderIdentity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
syncBonsplitNotificationBadges()
refreshGhosttyAppearanceConfig(reason: "onAppear")
}
.onChange(of: notificationStore.notifications) { _, _ in
syncBonsplitNotificationBadges()
}
.onChange(of: workspace.manualUnreadPanelIds) { _, _ in
syncBonsplitNotificationBadges()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
GhosttyConfig.invalidateLoadCache()
refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
}
.onChange(of: colorScheme) { oldValue, newValue in
// Keep split overlay color/opacity in sync with light/dark theme transitions.
refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
logTheme(
"theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
)
// Payload ordering can lag across rapid config/theme updates.
// Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned
// with Ghostty's current runtime theme.
refreshGhosttyAppearanceConfig(
reason: "ghosttyDefaultBackgroundDidChange",
backgroundEventId: eventId,
backgroundSource: source,
notificationPayloadHex: payloadHex
)
}
Group {
if isMinimalMode && !isFullScreen {
bonsplitView
.ignoresSafeArea(.container, edges: .top)
} else {
bonsplitView
}
}
}
private func syncBonsplitNotificationBadges() {
let manualUnread = workspace.manualUnreadPanelIds
for paneId in workspace.bonsplitController.allPaneIds {
for tab in workspace.bonsplitController.tabs(inPane: paneId) {
let panelId = workspace.panelIdFromSurfaceId(tab.id)
let expectedKind = panelId.flatMap { workspace.panelKind(panelId: $0) }
let expectedPinned = panelId.map { workspace.isPanelPinned($0) } ?? false
let shouldShow = panelId.map {
notificationStore.hasVisibleNotificationIndicator(forTabId: workspace.id, surfaceId: $0) ||
manualUnread.contains($0)
} ?? false
let kindUpdate: String?? = expectedKind.map { .some($0) }
if tab.showsNotificationBadge != shouldShow ||
tab.isPinned != expectedPinned ||
(expectedKind != nil && tab.kind != expectedKind) {
workspace.bonsplitController.updateTab(
tab.id,
kind: kindUpdate,
showsNotificationBadge: shouldShow,
isPinned: expectedPinned
)
}
}
}
}
private var splitZoomRenderIdentity: String {
workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed"
}
private static let tmuxWorkspacePaneTopChromeHeight: CGFloat = 30
private enum TmuxWorkspacePaneOverlayTrimMode {
case workspaceLocal
case windowContent
}
private static func tmuxWorkspacePaneContentRect(
_ rect: CGRect,
trimMode: TmuxWorkspacePaneOverlayTrimMode
) -> CGRect {
let topInset = min(tmuxWorkspacePaneTopChromeHeight, max(0, rect.height - 1))
switch trimMode {
case .workspaceLocal, .windowContent:
return CGRect(
x: rect.origin.x,
y: rect.origin.y + topInset,
width: rect.width,
height: max(0, rect.height - topInset)
)
}
}
private static func tmuxWorkspacePaneRect(
layoutSnapshot: LayoutSnapshot?,
paneId: PaneID?,
includeContainerOffset: Bool,
trimMode: TmuxWorkspacePaneOverlayTrimMode
) -> CGRect? {
guard let layoutSnapshot,
let paneId,
let paneRect = layoutSnapshot.panes
.first(where: { $0.paneId == paneId.id.uuidString })?
.frame
.cgRect else {
return nil
}
let rect: CGRect
if includeContainerOffset {
rect = paneRect.offsetBy(
dx: 0,
dy: -CGFloat(layoutSnapshot.containerFrame.y)
)
} else {
rect = paneRect.offsetBy(
dx: -CGFloat(layoutSnapshot.containerFrame.x),
dy: -CGFloat(layoutSnapshot.containerFrame.y)
)
}
return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode)
}
private static func tmuxWorkspacePaneRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
layoutSnapshot: LayoutSnapshot?,
includeContainerOffset: Bool,
trimMode: TmuxWorkspacePaneOverlayTrimMode
) -> [CGRect] {
guard let layoutSnapshot else { return [] }
return layoutSnapshot.panes.compactMap { pane in
guard let selectedTabId = pane.selectedTabId,
let tabUUID = UUID(uuidString: selectedTabId),
let panelId = workspace.panelIdFromSurfaceId(TabID(uuid: tabUUID)) else {
return nil
}
let shouldShowUnread = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
forTabId: workspace.id,
surfaceId: panelId
),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panelId)
)
guard shouldShowUnread else { return nil }
let paneRect = pane.frame.cgRect
let rect: CGRect
if includeContainerOffset {
rect = paneRect.offsetBy(
dx: 0,
dy: -CGFloat(layoutSnapshot.containerFrame.y)
)
} else {
rect = paneRect.offsetBy(
dx: -CGFloat(layoutSnapshot.containerFrame.x),
dy: -CGFloat(layoutSnapshot.containerFrame.y)
)
}
return tmuxWorkspacePaneContentRect(rect, trimMode: trimMode)
}
}
static func tmuxWorkspacePaneOverlayRect(
layoutSnapshot: LayoutSnapshot?,
paneId: PaneID?
) -> CGRect? {
tmuxWorkspacePaneRect(
layoutSnapshot: layoutSnapshot,
paneId: paneId,
includeContainerOffset: false,
trimMode: .workspaceLocal
)
}
static func tmuxWorkspacePaneWindowOverlayRect(
layoutSnapshot: LayoutSnapshot?,
paneId: PaneID?
) -> CGRect? {
tmuxWorkspacePaneRect(
layoutSnapshot: layoutSnapshot,
paneId: paneId,
includeContainerOffset: true,
trimMode: .windowContent
)
}
static func effectiveTmuxLayoutSnapshot(
cachedSnapshot: LayoutSnapshot?,
liveSnapshot: LayoutSnapshot?
) -> LayoutSnapshot? {
if let liveSnapshot,
tmuxLayoutSnapshotHasRenderableGeometry(liveSnapshot) {
return liveSnapshot
}
if let cachedSnapshot,
tmuxLayoutSnapshotHasRenderableGeometry(cachedSnapshot) {
return cachedSnapshot
}
return cachedSnapshot ?? liveSnapshot
}
static func tmuxWorkspacePaneUnreadRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
layoutSnapshot: LayoutSnapshot?
) -> [CGRect] {
tmuxWorkspacePaneRects(
workspace: workspace,
notificationStore: notificationStore,
layoutSnapshot: layoutSnapshot,
includeContainerOffset: false,
trimMode: .workspaceLocal
)
}
static func tmuxWorkspacePaneWindowUnreadRects(
workspace: Workspace,
notificationStore: TerminalNotificationStore,
layoutSnapshot: LayoutSnapshot?
) -> [CGRect] {
tmuxWorkspacePaneRects(
workspace: workspace,
notificationStore: notificationStore,
layoutSnapshot: layoutSnapshot,
includeContainerOffset: true,
trimMode: .windowContent
)
}
private static func tmuxLayoutSnapshotHasRenderableGeometry(_ snapshot: LayoutSnapshot) -> Bool {
snapshot.containerFrame.width > 1 &&
snapshot.containerFrame.height > 1 &&
snapshot.panes.contains { pane in
pane.frame.width > 1 && pane.frame.height > 1
}
}
static func resolveGhosttyAppearanceConfig(
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,
loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() },
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor },
defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity }
) -> GhosttyConfig {
var next = loadConfig()
let loadedBackgroundHex = next.backgroundColor.hexString()
let defaultBackgroundHex: String
let resolvedBackground: NSColor
if let backgroundOverride {
resolvedBackground = backgroundOverride
defaultBackgroundHex = "skipped"
} else {
let fallback = defaultBackground()
resolvedBackground = fallback
defaultBackgroundHex = fallback.hexString()
}
next.backgroundColor = resolvedBackground
// Use the runtime opacity from the Ghostty engine, which may differ from the
// file-level value parsed by GhosttyConfig.load().
next.backgroundOpacity = defaultBackgroundOpacity()
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) theme=\(next.theme ?? "nil")"
)
}
return next
}
private func refreshGhosttyAppearanceConfig(
reason: String,
backgroundOverride: NSColor? = nil,
backgroundEventId: UInt64? = nil,
backgroundSource: String? = nil,
notificationPayloadHex: String? = nil
) {
let previousBackgroundHex = config.backgroundColor.hexString()
let next = Self.resolveGhosttyAppearanceConfig(
reason: reason,
backgroundOverride: backgroundOverride
)
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
let sourceLabel = backgroundSource ?? "nil"
let payloadLabel = notificationPayloadHex ?? "nil"
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
let opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001
let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || reason == "onAppear"
logTheme(
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
)
withTransaction(Transaction(animation: nil)) {
config = next
if shouldRequestTitlebarRefresh {
onThemeRefreshRequest?(
reason,
backgroundEventId,
backgroundSource,
notificationPayloadHex
)
}
}
if !shouldRequestTitlebarRefresh {
logTheme(
"theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())"
)
}
logTheme(
"theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())"
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(
"theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
} else {
logTheme(
"theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
}
logTheme(
"theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
)
}
private func logTheme(_ message: String) {
guard GhosttyApp.shared.backgroundLogEnabled else { return }
GhosttyApp.shared.logBackground(message)
}
}
extension WorkspaceContentView {
#if DEBUG
static func debugPanelLookup(tab: Bonsplit.Tab, workspace: Workspace) {
let found = workspace.panel(for: tab.id) != nil
if !found {
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] PANEL NOT FOUND for tabId=\(tab.id) ws=\(workspace.id) panelCount=\(workspace.panels.count)\n"
let logPath = "/tmp/cmux-panel-debug.log"
if let handle = FileHandle(forWritingAtPath: logPath) {
handle.seekToEndOfFile()
handle.write(Data(line.utf8))
handle.closeFile()
} else {
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
}
}
}
#else
static func debugPanelLookup(tab: Bonsplit.Tab, workspace: Workspace) {
_ = tab
_ = workspace
}
#endif
}
/// View shown for empty panes
struct EmptyPanelView: View {
@ObservedObject var workspace: Workspace
let paneId: PaneID
@AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data()
private struct ShortcutHint: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.9))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.white.opacity(0.18), in: Capsule())
}
}
private func focusPane() {
workspace.bonsplitController.focusPane(paneId)
}
private func createTerminal() {
#if DEBUG
dlog("emptyPane.newTerminal pane=\(paneId.id.uuidString.prefix(5))")
#endif
focusPane()
_ = workspace.newTerminalSurface(inPane: paneId)
}
private func createBrowser() {
#if DEBUG
dlog("emptyPane.newBrowser pane=\(paneId.id.uuidString.prefix(5))")
#endif
focusPane()
_ = workspace.newBrowserSurface(inPane: paneId)
}
private var newSurfaceShortcut: StoredShortcut {
decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut)
}
private var openBrowserShortcut: StoredShortcut {
decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
@ViewBuilder
private func emptyPaneActionButton(
title: String,
systemImage: String,
shortcut: StoredShortcut,
action: @escaping () -> Void
) -> some View {
if let key = shortcut.keyEquivalent {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
} else {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
}
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal.fill")
.font(.system(size: 48))
.foregroundStyle(.tertiary)
Text("Empty Panel")
.font(.headline)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
emptyPaneActionButton(
title: "Terminal",
systemImage: "terminal.fill",
shortcut: newSurfaceShortcut,
action: createTerminal
)
emptyPaneActionButton(
title: "Browser",
systemImage: "globe",
shortcut: openBrowserShortcut,
action: createBrowser
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: GhosttyBackgroundTheme.currentColor()))
#if DEBUG
.onAppear {
DebugUIEventCounters.emptyPanelAppearCount += 1
}
#endif
}
}
#if DEBUG
@MainActor
enum DebugUIEventCounters {
static var emptyPanelAppearCount: Int = 0
static func resetEmptyPanelAppearCount() {
emptyPanelAppearCount = 0
}
}
#endif