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
17 changes: 0 additions & 17 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -14944,23 +14944,6 @@
}
}
},
"tabBar.autoTitle.pullRequest": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "PR #%lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "PR #%lld"
}
}
}
},
"tabBar.newTerminalTab": {
"extractionState": "manual",
"localizations": {
Expand Down
66 changes: 65 additions & 1 deletion Sources/WindowPaneChromePortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,18 @@ final class WindowPaneChromePortalRegistry: NSObject, BonsplitPaneChromePortalBr
var hostViewForTesting: NSView { hostView }

func nativeTabFramesForTesting(in paneID: PaneID) -> [NSRect] {
bars[paneID]?.tabFramesForTesting ?? []
bars[paneID]?.layoutSubtreeIfNeeded()
return bars[paneID]?.tabFramesForTesting ?? []
}

func nativeVisibleTabRectForTesting(in paneID: PaneID) -> NSRect {
bars[paneID]?.layoutSubtreeIfNeeded()
return bars[paneID]?.visibleDocumentRectForTesting ?? .zero
}

func nativeTabDocumentWidthForTesting(in paneID: PaneID) -> CGFloat {
bars[paneID]?.layoutSubtreeIfNeeded()
return bars[paneID]?.documentWidthForTesting ?? 0
}

func nativeTabInsertionIndexForTesting(in paneID: PaneID, documentX: CGFloat) -> Int? {
Expand Down Expand Up @@ -595,7 +606,9 @@ private final class TabStripScrollView: NSScrollView {
private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
private static let tabTransferPasteboardType =
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
private static let edgeFadeWidth: CGFloat = 24
private let scrollView = TabStripScrollView(frame: .zero)
private let edgeFadeMask = CAGradientLayer()
// 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 Down Expand Up @@ -649,6 +662,16 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
scrollView.horizontalScrollElasticity = .automatic
scrollView.verticalScrollElasticity = .none
scrollView.documentView = documentView
scrollView.wantsLayer = true
scrollView.contentView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(
self,
selector: #selector(clipBoundsDidChange),
name: NSView.boundsDidChangeNotification,
object: scrollView.contentView
)
edgeFadeMask.startPoint = CGPoint(x: 0, y: 0.5)
edgeFadeMask.endPoint = CGPoint(x: 1, y: 0.5)
addSubview(scrollView)
documentView.addSubview(dropIndicator, positioned: .above, relativeTo: nil)
registerForDraggedTypes([Self.tabTransferPasteboardType])
Expand All @@ -659,6 +682,8 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {

@objc private func newTabPressed() { newTabAction?() }

@objc private func clipBoundsDidChange(_ note: Notification) { updateEdgeFades() }

required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

override func layout() {
Expand Down Expand Up @@ -737,7 +762,9 @@ 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)
constrainClipOrigin()
revealPendingTab()
updateEdgeFades()
if let dropIndicatorIndex {
positionDropIndicator(at: dropIndicatorIndex)
}
Expand All @@ -758,6 +785,43 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
scrollView.reflectScrolledClipView(clip)
}

/// Closing tabs shrinks the document under a scrolled clip view; pull the
/// origin back inside the new bounds.
private func constrainClipOrigin() {
let clip = scrollView.contentView
let constrained = clip.constrainBoundsRect(clip.bounds).origin
guard constrained.x != clip.bounds.origin.x else { return }
clip.setBoundsOrigin(NSPoint(x: constrained.x, y: 0))
scrollView.reflectScrolledClipView(clip)
}

/// Fades the edge that has hidden tabs. The mask exists only while the row
/// overflows so a row that fits renders exactly as before.
private func updateEdgeFades() {
let bounds = scrollView.contentView.bounds
let hasLeading = bounds.minX > 0.5
let hasTrailing = bounds.maxX < documentView.frame.width - 0.5
let width = scrollView.bounds.width
CATransaction.begin()
CATransaction.setDisableActions(true)
defer { CATransaction.commit() }
guard hasLeading || hasTrailing, width > Self.edgeFadeWidth * 2 else {
scrollView.layer?.mask = nil
return
}
let fraction = Self.edgeFadeWidth / width
let opaque = NSColor.black.cgColor
let clear = NSColor.clear.cgColor
edgeFadeMask.frame = scrollView.bounds
edgeFadeMask.colors = [hasLeading ? clear : opaque, opaque, opaque, hasTrailing ? clear : opaque]
edgeFadeMask.locations = [0, fraction, 1 - fraction, 1].map { NSNumber(value: Double($0)) }
scrollView.layer?.mask = edgeFadeMask
}

fileprivate var visibleDocumentRectForTesting: NSRect { scrollView.contentView.bounds }

fileprivate var documentWidthForTesting: CGFloat { documentView.frame.width }

fileprivate var tabFramesForTesting: [NSRect] {
guard let descriptor else { return [] }
return descriptor.tabs.compactMap { pillViews[$0.id]?.frame }
Expand Down
30 changes: 0 additions & 30 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -831,39 +831,9 @@ final class Workspace: Identifiable, ObservableObject {
!custom.isEmpty {
return custom
}
if let pullRequest = panelPullRequests[panelId] {
let panelBranch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch)
let pullRequestBranch = normalizedSidebarBranchName(pullRequest.branch)
if pullRequestBranch == nil || panelBranch == pullRequestBranch {
return String(
localized: "tabBar.autoTitle.pullRequest",
defaultValue: "PR #\(pullRequest.number)"
)
}
}
if let branch = normalizedSidebarBranchName(panelGitBranches[panelId]?.branch) {
return Self.ticketIdentifier(in: branch) ?? branch
}
return fallbackTitle
}

private static let ticketIdentifierPattern = try? NSRegularExpression(
pattern: #"(?<![A-Za-z0-9])([A-Z][A-Z0-9]+-[0-9]+)(?![A-Za-z0-9])"#
)

private static func ticketIdentifier(in branch: String) -> String? {
guard let ticketIdentifierPattern,
let match = ticketIdentifierPattern.firstMatch(
in: branch,
range: NSRange(branch.startIndex..., in: branch)
),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: branch) else {
return nil
}
return String(branch[range])
}

func syncResolvedPanelTitle(panelId: UUID) {
guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return }
let baseTitle = panelTitles[panelId] ?? panel.displayTitle
Expand Down
67 changes: 67 additions & 0 deletions programaTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,73 @@ final class WindowGlassEffectTests: XCTestCase {
throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime")
}

func testNativePaneTabBarKeepsTitlesReadableAndScrollsSelectedTabIntoView() throws {
#if compiler(>=6.2)
if #available(macOS 26.0, *) {
_ = NSApplication.shared
let root = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 320))
let terminalHost = WindowTerminalHostView(frame: root.bounds)
let anchor = NSView(frame: NSRect(x: 20, y: 270, width: 300, height: 40))
root.addSubview(anchor)
root.addSubview(terminalHost, positioned: .above, relativeTo: anchor)
let window = NSWindow(
contentRect: root.bounds,
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
window.contentView = root

let bridge = WindowPaneChromePortalRegistry.bridge(for: window)
let paneID = PaneID()
let ids = (0..<15).map { _ in TabID() }
func publish(selected: Int) {
let tabs = ids.enumerated().map { index, id in
BonsplitPaneChromeTabDescriptor(
id: id, title: "A long terminal title number \(index)", icon: "terminal",
iconImageData: nil, isSelected: index == selected, isPinned: false,
isDirty: false, showsNotificationBadge: false, accessibilityValue: "",
menuItems: []
)
}
bridge.updatePaneChrome(BonsplitPaneChromeDescriptor(
paneID: paneID, anchorView: anchor, tabs: tabs, isFocused: true,
isVisible: true, leadingInset: 0, showsSplitButtons: false,
onSelect: { _ in }, onClose: { _ in }, onContextAction: { _, _ in },
dragPasteboardData: { _ in nil }, onDragStateChanged: { _, _ in },
validatedDropIndex: { (0...tabs.count).contains($0) ? $0 : nil },
onDropTab: { _ in true },
onNewTab: {}, onSplitRight: {}, onSplitDown: {}
))
root.layoutSubtreeIfNeeded()
}

publish(selected: 0)
let readableMinimumWidth: CGFloat = 78
let frames = bridge.nativeTabFramesForTesting(in: paneID)
XCTAssertEqual(frames.count, ids.count)
for frame in frames {
XCTAssertGreaterThanOrEqual(frame.width, readableMinimumWidth)
}
let visible = bridge.nativeVisibleTabRectForTesting(in: paneID)
XCTAssertGreaterThan(visible.width, 0)
XCTAssertGreaterThan(bridge.nativeTabDocumentWidthForTesting(in: paneID), visible.width)
XCTAssertLessThanOrEqual(try XCTUnwrap(frames.first).minX, visible.minX + 0.5)

publish(selected: ids.count - 1)
let scrolledVisible = bridge.nativeVisibleTabRectForTesting(in: paneID)
let lastFrame = try XCTUnwrap(bridge.nativeTabFramesForTesting(in: paneID).last)
XCTAssertGreaterThan(scrolledVisible.minX, 0)
XCTAssertGreaterThanOrEqual(lastFrame.minX, scrolledVisible.minX)
XCTAssertLessThanOrEqual(lastFrame.maxX, scrolledVisible.maxX)
return
}
#endif

throw XCTSkip("Native Liquid Glass requires the macOS 26 SDK and runtime")
}

func testNativePaneTabInsertionIndicatorStaysInsideDocumentBounds() throws {
#if compiler(>=6.2)
let bounds = NSRect(x: 0, y: 0, width: 240, height: 28)
Expand Down
Loading
Loading