Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,66 @@ final class SplitViewController {
/// Callback for geometry changes
var onGeometryChange: (() -> Void)?

@ObservationIgnored private var tabBarRenderWindowStart: TimeInterval = 0
@ObservationIgnored private var tabBarRenderWindowLastRender: TimeInterval = 0
@ObservationIgnored private var tabBarRenderWindowRenderCount = 0
@ObservationIgnored private var tabBarRenderWindowTabCount = 0
@ObservationIgnored private var tabBarRenderWindowPanes: Set<PaneID> = []
@ObservationIgnored private var tabBarRenderWindowReported = false

/// Records a tab-bar body evaluation and emits one diagnostic for a sustained
/// render window. This is intentionally inactive unless the host installs a
/// diagnostic handler. Hosts that install the handler pay the small counter
/// cost needed to identify a sustained render storm.
func recordTabBarRender(
paneId: PaneID,
tabCount: Int,
now: TimeInterval = ProcessInfo.processInfo.systemUptime
) {
guard let handler = publicController?.onTabBarRenderDiagnostic else {
resetTabBarRenderWindow()
return
}

if tabBarRenderWindowStart == 0
|| now - tabBarRenderWindowLastRender > 0.1
|| now - tabBarRenderWindowStart > 2 {
resetTabBarRenderWindow()
tabBarRenderWindowStart = now
}

tabBarRenderWindowLastRender = now
tabBarRenderWindowRenderCount += 1
tabBarRenderWindowTabCount += tabCount
tabBarRenderWindowPanes.insert(paneId)

let elapsed = now - tabBarRenderWindowStart
guard !tabBarRenderWindowReported,
elapsed >= 0.5,
tabBarRenderWindowRenderCount >= 32 else {
return
}

tabBarRenderWindowReported = true
handler(
TabBarRenderDiagnostic(
elapsed: elapsed,
renderCount: tabBarRenderWindowRenderCount,
tabCount: tabBarRenderWindowTabCount,
paneCount: tabBarRenderWindowPanes.count
)
)
}

private func resetTabBarRenderWindow() {
tabBarRenderWindowStart = 0
tabBarRenderWindowLastRender = 0
tabBarRenderWindowRenderCount = 0
tabBarRenderWindowTabCount = 0
tabBarRenderWindowPanes.removeAll(keepingCapacity: true)
tabBarRenderWindowReported = false
}

/// Live divider drag sessions across every split in this tree (0 or 1 in
/// practice — AppKit tracks one divider at a time). Sessions bracket the
/// divider's mouse-tracking lifecycle, so external sizing can consult
Expand Down
36 changes: 23 additions & 13 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,10 @@ struct TabBarView: View {
}

var body: some View {
let _ = controller.internalController.recordTabBarRender(
paneId: pane.id,
tabCount: pane.tabs.count
)
HStack(spacing: 0) {
if appearance.tabBarLeadingInset > 0 && controller.internalController.rootNode.allPaneIds.first == pane.id {
TabBarDragZoneView(
Expand Down Expand Up @@ -1361,7 +1365,6 @@ struct TabBarView: View {
fillsWidth: Bool? = nil
) -> some View {
let tabFillsWidth = fillsWidth ?? fillsTabsToWidth
let contextMenuState = contextMenuState(for: tab, at: index)
let showsZoomIndicator = splitViewController.zoomedPaneId == pane.id && pane.selectedTabId == tab.id
let isImmediatelyBeforeSelected = pane.tabs.indices.contains(index + 1)
&& pane.tabs[index + 1].id == pane.selectedTabId
Expand All @@ -1386,7 +1389,15 @@ struct TabBarView: View {
allowsClose: controller.configuration.allowCloseTabs,
middleClickCapture: controller.tabMiddleClickCapture,
allowsContextMenu: controller.configuration.allowsTabContextMenu,
contextMenuState: contextMenuState,
contextMenuStateProvider: { [pane, controller, splitViewController] in
TabContextMenuState(
tab: tab,
index: index,
pane: pane,
controller: controller,
splitViewController: splitViewController
)
},
moveDestinationsProvider: {
controller.tabContextMoveDestinationsProvider?(TabID(id: tab.id), pane.id) ?? []
},
Expand Down Expand Up @@ -1448,16 +1459,6 @@ struct TabBarView: View {
}
}

private func contextMenuState(for tab: TabItem, at index: Int) -> TabContextMenuState {
return TabContextMenuState(
tab: tab,
index: index,
pane: pane,
controller: controller,
splitViewController: splitViewController
)
}

private func tabControlShortcutDigit(for index: Int, tabCount: Int) -> Int? {
for digit in 1...9 {
if tabIndexForControlShortcutDigit(digit, tabCount: tabCount) == index {
Expand Down Expand Up @@ -3313,7 +3314,11 @@ private struct TabBarHostWindowReader: NSViewRepresentable {
@Observable
private final class TabControlShortcutKeyMonitor {
private(set) var isShortcutHintVisible = false
private(set) var shortcutModifierSymbol = TabControlShortcutHintPolicy.configuredShortcutModifierSymbol()
/// `start()` resolves the configured symbol. The initial value is a
/// constant because `@State` evaluates it on every `TabBarView` init and
/// discards it after the first; reading user defaults here ran a defaults
/// lookup and JSON decode for every pane on every tab-bar rebuild.
private(set) var shortcutModifierSymbol = TabControlShortcutSettings.defaultShortcut.modifierSymbol

@ObservationIgnored private weak var hostWindow: NSWindow?
@ObservationIgnored private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
Expand Down Expand Up @@ -3357,6 +3362,11 @@ private final class TabControlShortcutKeyMonitor {
}

func start() {
// Resolve the configured symbol before the first update: one that
// queues a hint while the modifier is already held keeps the symbol.
if !isShortcutHintVisible {
shortcutModifierSymbol = TabControlShortcutHintPolicy.configuredShortcutModifierSymbol()
}
guard flagsMonitor == nil else {
update(from: NSEvent.modifierFlags, eventWindow: nil)
return
Expand Down
36 changes: 34 additions & 2 deletions Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,56 @@ import SwiftUI

struct TabContextMenuSnapshot {
let tabId: UUID
let state: TabContextMenuState
/// Builds the menu's tab state. The tab strip renders every tab far more
/// often than a menu opens, and the state calls host providers and walks
/// the pane tree, so it is computed only when a menu is built.
private let stateProvider: () -> TabContextMenuState
let moveDestinationsProvider: () -> [TabContextMoveDestination]
let forkConversationAvailabilityProvider: () -> TabContextForkConversationAvailability
let forkConversationAvailabilityRefreshHandler: @MainActor () async -> Void

var state: TabContextMenuState { stateProvider() }

init(
tabId: UUID,
state: TabContextMenuState,
moveDestinationsProvider: @escaping () -> [TabContextMoveDestination],
forkConversationAvailabilityProvider: @escaping () -> TabContextForkConversationAvailability,
forkConversationAvailabilityRefreshHandler: @escaping @MainActor () async -> Void = {}
) {
self.init(
tabId: tabId,
stateProvider: { state },
moveDestinationsProvider: moveDestinationsProvider,
forkConversationAvailabilityProvider: forkConversationAvailabilityProvider,
forkConversationAvailabilityRefreshHandler: forkConversationAvailabilityRefreshHandler
)
}

init(
tabId: UUID,
stateProvider: @escaping () -> TabContextMenuState,
moveDestinationsProvider: @escaping () -> [TabContextMoveDestination],
forkConversationAvailabilityProvider: @escaping () -> TabContextForkConversationAvailability,
forkConversationAvailabilityRefreshHandler: @escaping @MainActor () async -> Void = {}
) {
self.tabId = tabId
self.state = state
self.stateProvider = stateProvider
self.moveDestinationsProvider = moveDestinationsProvider
self.forkConversationAvailabilityProvider = forkConversationAvailabilityProvider
self.forkConversationAvailabilityRefreshHandler = forkConversationAvailabilityRefreshHandler
}

/// A copy whose state is computed now, so one menu build reads one value.
func resolvingState() -> TabContextMenuSnapshot {
TabContextMenuSnapshot(
tabId: tabId,
state: state,
moveDestinationsProvider: moveDestinationsProvider,
forkConversationAvailabilityProvider: forkConversationAvailabilityProvider,
forkConversationAvailabilityRefreshHandler: forkConversationAvailabilityRefreshHandler
)
}
}

final class TabContextMenuActionTarget: NSObject {
Expand Down
11 changes: 7 additions & 4 deletions Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ struct TabItemView: View {
let allowsClose: Bool
let middleClickCapture: ((@escaping () -> Void) -> AnyView)?
let allowsContextMenu: Bool
let contextMenuState: TabContextMenuState
let contextMenuStateProvider: () -> TabContextMenuState
let moveDestinationsProvider: () -> [TabContextMoveDestination]
let forkConversationAvailabilityProvider: () -> TabContextForkConversationAvailability
let forkConversationAvailabilityRefreshHandler: @MainActor () async -> Void
Expand Down Expand Up @@ -389,7 +389,9 @@ struct TabItemView: View {
.fixedSize(horizontal: isIconOnlyPinned || !fillsWidth, vertical: false)
.background(tabBackground.saturation(saturation))
.background(TabPopoverAnchorView(tabId: tab.id, kind: .tabItem))
.tabControlShortcutHintVisibilityAnimation(value: showsShortcutHint)
// The hint crossfade is animated where it happens (`trailingAccessory`,
// `iconOnlyContent`). An animation here would wrap the whole tab and
// copy its entire view value on every render.
.contentShape(Rectangle().inset(by: -BonsplitTabItemHitTesting.horizontalSlop))
// Middle click to close (macOS convention).
// The host capture view intercepts mouse-down on this tab without
Expand All @@ -407,7 +409,7 @@ struct TabItemView: View {
TabContextMenuPresenter(
snapshot: TabContextMenuSnapshot(
tabId: tab.id,
state: contextMenuState,
stateProvider: contextMenuStateProvider,
moveDestinationsProvider: moveDestinationsProvider,
forkConversationAvailabilityProvider: forkConversationAvailabilityProvider,
forkConversationAvailabilityRefreshHandler: forkConversationAvailabilityRefreshHandler
Expand Down Expand Up @@ -1446,9 +1448,10 @@ enum TabContextMenuBuilder {
)

static func makeMenu(
snapshot: TabContextMenuSnapshot,
snapshot unresolvedSnapshot: TabContextMenuSnapshot,
target: TabContextMenuActionTarget
) -> TabContextMenu {
let snapshot = unresolvedSnapshot.resolvingState()
let state = snapshot.state
let forkConversationAvailability = snapshot.forkConversationAvailabilityProvider()
let forkConversationEnabled = forkConversationAvailability == .available
Expand Down
5 changes: 5 additions & 0 deletions Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ public final class BonsplitController {
/// on the host UI package.
@ObservationIgnored public var tabMiddleClickCapture: ((@escaping () -> Void) -> AnyView)?

/// Called once when tab-bar body evaluation remains active across a sustained
/// render window. Hosts can use this low-volume signal to diagnose main-thread
/// render storms without adding work to ordinary tab-bar updates.
@ObservationIgnored public var onTabBarRenderDiagnostic: (@MainActor (TabBarRenderDiagnostic) -> Void)?

/// Called when the user explicitly requests to toggle zoom from tab chrome.
/// When set, the host owns the full toggle and should return whether it succeeded.
@ObservationIgnored public var onTabZoomToggleRequest: (@MainActor (_ tabId: TabID, _ paneId: PaneID) -> Bool)?
Expand Down
24 changes: 24 additions & 0 deletions Sources/Bonsplit/Public/Types/TabBarRenderDiagnostic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

/// A sampled report that tab-bar rendering stayed active across many view updates.
public struct TabBarRenderDiagnostic: Sendable {
/// Wall-clock duration covered by the sampled render window.
public let elapsed: TimeInterval

/// Number of tab-bar body evaluations in the sampled window.
public let renderCount: Int

/// Sum of tab counts observed across those evaluations.
public let tabCount: Int

/// Number of distinct panes observed across those evaluations.
public let paneCount: Int

/// Creates a diagnostic from one sustained tab-bar render window.
public init(elapsed: TimeInterval, renderCount: Int, tabCount: Int, paneCount: Int) {
self.elapsed = elapsed
self.renderCount = renderCount
self.tabCount = tabCount
self.paneCount = paneCount
}
}
Loading
Loading