Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- On Windows, pasting text with line breaks or control characters into a program that has not turned on bracketed paste (cmd.exe, PowerShell) asks first, with a preview.

### Changed
- A pane with a single tab hides its tab bar, along with the new-tab and split buttons. The bar comes back when the pane gets a second tab.
- A focused terminal that has nothing new to show uses about half as much CPU. It stops waking up on every screen refresh after a quarter second of quiet and starts again on the next change.
- Only one copy of Programa runs at a time: launching a second copy brings the running app forward and exits.
- Closing a window with its red button or Close Window asks first when any of its workspaces has a running process, with the same confirmation as closing the last workspace. The socket method `window.close` still closes without asking.
Expand Down Expand Up @@ -44,6 +45,8 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
- The `programa codex install-hooks` and `uninstall-hooks` legacy aliases. Use `programa codex install-integration` and `uninstall-integration`.

### Fixed
- With dozens of tabs in a pane, each tab keeps a width that shows its title instead of shrinking to one letter. The tab bar scrolls with a regular mouse wheel and keeps the selected tab in view.
- Renaming a tab, or a program changing its title, updates the tab bar right away.
- Launching a second build of Programa no longer cuts the running app's command line tool and agent hooks off. The new copy used to delete the running app's socket and take it over.
- Dragging the sidebar edge resizes the sidebar again instead of moving the whole window.
- The Window menu's Fill, Center, Move & Resize, Full Screen Tile and Move to Display items, and their shortcuts, work again. The main window had been marked not movable, which makes macOS disable all of them.
Expand Down
67 changes: 54 additions & 13 deletions Sources/WindowPaneChromePortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr
// descriptors must not steer this window's controls.
let inWindow = descriptors.values.filter { $0.anchorView?.window === window }
let visible = inWindow.filter(\.isVisible)
guard let active = visible.first(where: { $0.isFocused }) ?? visible.first,
// A focused pane with a hidden strip (single tab) hides the controls instead of
// lending them to another pane, where they would act on the wrong pane.
let focused = inWindow.first(where: \.isFocused)
guard let active = focused.map({ $0.isVisible ? $0 : nil }) ?? visible.first,
let anchor = active.anchorView else {
newTabCluster.isHidden = true
splitCluster.isHidden = true
Expand Down Expand Up @@ -565,12 +568,34 @@ enum NativePaneTabInsertionPolicy {
}
}

/// Horizontal tab strip that also scrolls sideways from a plain mouse wheel.
private final class TabStripScrollView: NSScrollView {
override func scrollWheel(with event: NSEvent) {
// A mouse wheel reports a vertical delta with no gesture phase; trackpads and
// Magic Mouse report phases (and horizontal deltas) and keep native handling.
guard event.phase.isEmpty, event.momentumPhase.isEmpty,
event.scrollingDeltaX == 0, event.scrollingDeltaY != 0,
let documentView else {
super.scrollWheel(with: event)
return
}
let delta = event.hasPreciseScrollingDeltas
? event.scrollingDeltaY
: event.scrollingDeltaY * horizontalLineScroll
let clip = contentView
let maxX = max(0, documentView.frame.width - clip.bounds.width)
let x = min(max(0, clip.bounds.origin.x - delta), maxX)
clip.scroll(to: NSPoint(x: x, y: 0))
reflectScrolledClipView(clip)
}
}

@MainActor
@available(macOS 26.0, *)
private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
private static let tabTransferPasteboardType =
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private let scrollView = NSScrollView(frame: .zero)
private let scrollView = TabStripScrollView(frame: .zero)
// Unflipped on purpose: a flipped document view mirrors the glass pills'
// built-in shadow upward (layer geometry flip flips shadowOffset), while
// the control capsules in the unflipped host cast theirs downward. Layout
Expand All @@ -587,6 +612,9 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
return view
}()
private var dropIndicatorIndex: Int?
/// Selected tab that still has to be scrolled into view once layout knows its frame.
private var pendingRevealTabID: TabID?
private var lastSelectedTabID: TabID?
/// Safari-style "+" after the last pill; scrolls with the tabs. Bare glyph,
/// no capsule — it's an affordance, not a peer of the tab pills.
private let newTabButton: NSButton = {
Expand Down Expand Up @@ -673,6 +701,11 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
)
}
newTabAction = descriptor.onNewTab
let selectedTabID = descriptor.tabs.first(where: \.isSelected)?.id
if selectedTabID != lastSelectedTabID {
lastSelectedTabID = selectedTabID
pendingRevealTabID = selectedTabID
}
if let dropIndicatorIndex,
descriptor.validatedDropIndex(dropIndicatorIndex) == nil {
clearDropIndicator()
Expand All @@ -691,17 +724,9 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
let pills = descriptor.tabs.compactMap { pillViews[$0.id] }
let plusWidth: CGFloat = 28

// Natural width per pill; only compress (which is what introduces
// truncation) once the row genuinely runs out of space. The "+" always
// keeps its seat at the end of the row.
var widths = pills.map { min(220, max(minPillWidth, $0.preferredWidth)) }
let available = scrollView.contentSize.width - leadingInset - (gap + plusWidth)
let naturalTotal = widths.reduce(0, +) + gap * CGFloat(max(0, widths.count - 1))
if naturalTotal > available, !widths.isEmpty {
let evenWidth = (available - gap * CGFloat(widths.count - 1)) / CGFloat(widths.count)
let compressed = max(minPillWidth, evenWidth.rounded(.down))
widths = widths.map { min($0, max(compressed, minPillWidth)) }
}
// Pills keep a width that fits their title; a row wider than the strip
// scrolls instead of squeezing every title down to a clipped glyph.
let widths = pills.map { min(220, max(minPillWidth, $0.preferredWidth)) }

var x = leadingInset
for (pill, width) in zip(pills, widths) {
Expand All @@ -712,11 +737,27 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
newTabButton.frame = NSRect(x: x, y: 0, width: plusWidth, height: height)
x += plusWidth + gap
documentView.frame = NSRect(x: 0, y: 0, width: max(x, scrollView.contentSize.width), height: height)
revealPendingTab()
if let dropIndicatorIndex {
positionDropIndicator(at: dropIndicatorIndex)
}
}

/// Centers the newly selected tab in the strip, like the SwiftUI strip's
/// `scrollTo(anchor: .center)`. Runs once per selection change so manual scrolling
/// is never undone by a later layout pass.
private func revealPendingTab() {
guard let tabID = pendingRevealTabID else { return }
let clip = scrollView.contentView
// Early layout passes run before the strip has a size; keep the request pending.
guard clip.bounds.width > 0, let pill = pillViews[tabID], pill.frame.width > 0 else { return }
pendingRevealTabID = nil
let maxX = max(0, documentView.frame.width - clip.bounds.width)
let targetX = min(max(0, pill.frame.midX - clip.bounds.width / 2), maxX)
clip.scroll(to: NSPoint(x: targetX, y: 0))
scrollView.reflectScrolledClipView(clip)
}

fileprivate var tabFramesForTesting: [NSRect] {
guard let descriptor else { return [] }
return descriptor.tabs.compactMap { pillViews[$0.id]?.frame }
Expand Down
2 changes: 0 additions & 2 deletions vendor/bonsplit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,6 @@ newTabPosition: .end
```swift
let appearance = BonsplitConfiguration.Appearance(
tabBarHeight: 33,
tabMinWidth: 140,
tabMaxWidth: 220,
tabSpacing: 0,
minimumPaneWidth: 100,
minimumPaneHeight: 100,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable {
weak var splitController: SplitViewController?
weak var bonsplitController: BonsplitController?
let tabs: [TabItem]
/// `TabItem ==` compares ids only, so SwiftUI treats `tabs` as unchanged when a
/// title, icon, or badge changes and skips `updateNSView`. This forces the diff.
let tabsContentSignature: Int
let selectedTabID: UUID?
let isFocused: Bool
let isVisible: Bool
Expand Down Expand Up @@ -548,6 +551,20 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
bonsplitController.paneChromePortalBridge?.supportsNativePaneChrome == true
}

private var leadingInset: CGFloat {
controller.rootNode.allPaneIds.first == pane.id
? bonsplitController.configuration.appearance.tabBarLeadingInset
: 0
}

/// A pane with exactly one tab has nothing to switch between, so its strip is
/// hidden. A nonzero `leadingInset` means the strip doubles as the titlebar row
/// beside the traffic lights (minimal mode, sidebar hidden); it stays so the
/// terminal does not slide under them and the window remains draggable there.
private var showsTabStrip: Bool {
pane.tabs.count != 1 || leadingInset > 0
}

var body: some View {
VStack(spacing: 0) {
if usesNativePaneChrome {
Expand All @@ -556,18 +573,17 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
splitController: controller,
bonsplitController: bonsplitController,
tabs: pane.tabs,
tabsContentSignature: Self.contentSignature(of: pane.tabs),
selectedTabID: pane.selectedTabId,
isFocused: isFocused,
isVisible: bonsplitController.isInteractive,
leadingInset: controller.rootNode.allPaneIds.first == pane.id
? bonsplitController.configuration.appearance.tabBarLeadingInset
: 0,
isVisible: bonsplitController.isInteractive && showsTabStrip,
leadingInset: leadingInset,
showsSplitButtons: showSplitButtons
)
.frame(height: TabBarGlassStyling.barHeight)
.frame(height: showsTabStrip ? TabBarGlassStyling.barHeight : 0)
.allowsHitTesting(false)
.accessibilityHidden(true)
} else {
} else if showsTabStrip {
// macOS 14–25 and bridge-unavailable windows retain the flat SwiftUI strip.
TabBarView(
pane: pane,
Expand Down Expand Up @@ -609,6 +625,24 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
}
}

private static func contentSignature(of tabs: [TabItem]) -> Int {
var hasher = Hasher()
for tab in tabs {
hasher.combine(tab.id)
hasher.combine(tab.title)
hasher.combine(tab.hasCustomTitle)
hasher.combine(tab.icon)
hasher.combine(tab.iconImageData)
hasher.combine(tab.kind)
hasher.combine(tab.isDirty)
hasher.combine(tab.showsNotificationBadge)
hasher.combine(tab.isLoading)
hasher.combine(tab.isPinned)
hasher.combine(tab.customColorHex)
}
return hasher.finalize()
}

// MARK: - Content Area with Drop Zones

@ViewBuilder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,6 @@ extension BonsplitConfiguration {

// MARK: - Tabs

/// Minimum width of a tab
public var tabMinWidth: CGFloat

/// Maximum width of a tab
public var tabMaxWidth: CGFloat

/// Spacing between tabs
public var tabSpacing: CGFloat

Expand Down Expand Up @@ -202,8 +196,6 @@ extension BonsplitConfiguration {

public init(
tabBarHeight: CGFloat = 33,
tabMinWidth: CGFloat = 140,
tabMaxWidth: CGFloat = 220,
tabSpacing: CGFloat = 0,
minimumPaneWidth: CGFloat = 100,
minimumPaneHeight: CGFloat = 100,
Expand All @@ -218,8 +210,6 @@ extension BonsplitConfiguration {
chromeColors: ChromeColors = .init()
) {
self.tabBarHeight = tabBarHeight
self.tabMinWidth = tabMinWidth
self.tabMaxWidth = tabMaxWidth
self.tabSpacing = tabSpacing
self.minimumPaneWidth = minimumPaneWidth
self.minimumPaneHeight = minimumPaneHeight
Expand Down
Loading