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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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 workspace with a single tab hides its tab bar, along with the new-tab and split buttons, and leaves an empty strip in its place so the window can still be dragged from the top. A glass + button in that strip's right corner opens a new tab. The bar comes back when the pane gets a second tab or the workspace is split, so every split pane keeps its close and new-tab buttons.
- 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
17 changes: 16 additions & 1 deletion Sources/AppDelegate+UITestHarnesses.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ extension AppDelegate {
guard env["PROGRAMA_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return }
guard tabManager != nil else { return }
let startWithHiddenSidebar = env["PROGRAMA_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1"
let splitPanes = env["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] == "1"
let singleTab = env["PROGRAMA_UI_TEST_BONSPLIT_SINGLE_TAB"] == "1"

let deadline = Date().addingTimeInterval(20.0)
func hasMainTerminalWindow() -> Bool {
Expand Down Expand Up @@ -204,7 +206,20 @@ extension AppDelegate {
let betaTitle = "UITest Beta"
tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle)
workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle)
tabManager.newSurface()
if singleTab {
self.writeBonsplitTabDragUITestData([
"ready": "1",
"workspaceId": workspace.id.uuidString,
"alphaTitle": alphaTitle,
"alphaPanelId": alphaPanelId.uuidString,
])
return
}
if splitPanes {
tabManager.createSplit(direction: .right)
} else {
tabManager.newSurface()
}

guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else {
self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"])
Expand Down
79 changes: 79 additions & 0 deletions programaUITests/BonsplitTabDragUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,86 @@ final class BonsplitTabDragUITests: XCTestCase {
)
}

func testSplitPanesWithOneTabEachKeepTheirTabBars() {
let (app, dataPath) = launchConfiguredApp(splitPanes: true)

XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: launchTimeout),
"Expected app to launch for split-pane tab bar UI test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)")
guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else {
XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])")
return
}

if let setupError = ready["setupError"], !setupError.isEmpty {
XCTFail("Setup failed: \(setupError)")
return
}

let window = app.windows.element(boundBy: 0)
XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist")

// Each pane holds a single tab. Its tab bar still has to show, since that bar
// carries the close and new-tab controls that undo the split. `isHittable` is
// not usable here: XCUI cannot hit-test through the macOS 26 glass tab pills.
let alphaTab = app.buttons[ready["alphaTitle"] ?? "UITest Alpha"]
let betaTab = app.buttons[ready["betaTitle"] ?? "UITest Beta"]
XCTAssertTrue(
waitForCondition(timeout: 5.0) { alphaTab.exists && window.frame.contains(alphaTab.frame) },
"Expected the left pane's tab bar to show its only tab. window=\(window.frame) alpha=\(alphaTab.debugDescription)"
)
XCTAssertTrue(
waitForCondition(timeout: 5.0) { betaTab.exists && window.frame.contains(betaTab.frame) },
"Expected the right pane's tab bar to show its only tab. window=\(window.frame) beta=\(betaTab.debugDescription)"
)
XCTAssertLessThan(alphaTab.frame.midX, betaTab.frame.midX, "Expected alpha and beta in side-by-side panes")
}

func testSingleTabFloatingNewTabButtonOpensATab() {
let (app, dataPath) = launchConfiguredApp(singleTab: true)

XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: launchTimeout),
"Expected app to launch for floating new-tab button UI test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)")
guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else {
XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])")
return
}
if let setupError = ready["setupError"], !setupError.isEmpty {
XCTFail("Setup failed: \(setupError)")
return
}

let window = app.windows.element(boundBy: 0)
XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist")

// A single tab hides its tab bar, so the strip left in its place carries the "+".
let alphaTab = app.buttons[ready["alphaTitle"] ?? "UITest Alpha"]
let newTabButton = app.descendants(matching: .any).matching(identifier: "paneFloatingNewTabButton").firstMatch
XCTAssertTrue(newTabButton.waitForExistence(timeout: 5.0), "Expected the floating new-tab button with a single tab")
XCTAssertFalse(alphaTab.exists, "Expected the single tab's tab bar to be hidden")

newTabButton.click()

XCTAssertTrue(
waitForCondition(timeout: 5.0) { alphaTab.exists && !newTabButton.exists },
"Expected the click to open a second tab and bring the tab bar back. alpha=\(alphaTab.debugDescription)"
)
}

private enum WorkspacePresentationMode: String {
case standard
case minimal
}

private func launchConfiguredApp(
startWithHiddenSidebar: Bool = false,
splitPanes: Bool = false,
singleTab: Bool = false,
presentationMode: WorkspacePresentationMode = .minimal
) -> (XCUIApplication, String) {
let app = XCUIApplication()
Expand All @@ -436,6 +509,12 @@ final class BonsplitTabDragUITests: XCTestCase {
if startWithHiddenSidebar {
app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1"
}
if splitPanes {
app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_SPLIT_PANES"] = "1"
}
if singleTab {
app.launchEnvironment["PROGRAMA_UI_TEST_BONSPLIT_SINGLE_TAB"] = "1"
}
app.launchArguments += ["-workspacePresentationMode", presentationMode.rawValue]
app.launch()
app.activate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,12 +558,14 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
: 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.
/// The only pane of a workspace with exactly one tab has nothing to switch
/// between, so its strip is hidden and an empty drag band takes its place. Once the workspace is split, every pane keeps
/// its strip: it carries the close, new-tab and split controls that undo the split.
/// 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
pane.tabs.count != 1 || leadingInset > 0 || controller.rootNode.allPaneIds.count > 1
}

var body: some View {
Expand Down Expand Up @@ -592,6 +594,23 @@ struct PaneContainerView<Content: View, EmptyContent: View>: View {
showSplitButtons: showSplitButtons
)
}
if !showsTabStrip {
// The hidden strip's pane sits at the window top, so this band stands
// in for the titlebar: without it nothing up there drags the window.
TabBarDragZoneView()
.frame(height: usesNativePaneChrome ? TabBarGlassStyling.barHeight : TabBarMetrics.barHeight)
.accessibilityHidden(true)
.overlay(alignment: .trailing) {
FloatingNewTabButton(
title: Bundle.module.localizedString(forKey: "tabBar.newTab", value: "New Tab", table: nil)
) { [weak pane, weak bonsplitController] in
guard let pane, let bonsplitController else { return }
bonsplitController.requestNewTab(kind: "terminal", inPane: pane.id)
}
.frame(width: 24, height: 22)
.padding(.trailing, 8)
}
}

// Content area with drop zones
contentAreaWithDropZones
Expand Down Expand Up @@ -1011,3 +1030,51 @@ struct UnifiedPaneDropDelegate: DropDelegate {
return nil
}
}

/// The "+" that opens a new tab from the empty strip a single tab leaves behind.
/// It is an AppKit button on purpose: the window's titlebar-drag routing in
/// `programa_sendEvent` turns a click on plain SwiftUI content there into a window drag.
private struct FloatingNewTabButton: NSViewRepresentable {
let title: String
let action: () -> Void

func makeCoordinator() -> Coordinator { Coordinator(action: action) }

func makeNSView(context: Context) -> NSView {
let button = NSButton(frame: .zero)
button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: title)
button.isBordered = false
button.contentTintColor = .secondaryLabelColor
button.toolTip = title
button.setAccessibilityLabel(title)
button.setAccessibilityIdentifier("paneFloatingNewTabButton")
button.target = context.coordinator
button.action = #selector(Coordinator.pressed)
#if compiler(>=6.2)
if #available(macOS 26.0, *) {
let glass = NSGlassEffectView(frame: .zero)
glass.style = .regular
glass.cornerRadius = 11
glass.contentView = button
return glass
}
#endif
return button
}

func updateNSView(_ nsView: NSView, context: Context) {
context.coordinator.action = action
}

final class Coordinator: NSObject {
var action: () -> Void

init(action: @escaping () -> Void) {
self.action = action
}

@objc func pressed() {
action()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
"contextMenu.tabColor" = "Tab Color";
"contextMenu.clearColor" = "Clear Color";
"contextMenu.chooseCustomColor" = "Choose Custom Color…";
"tabBar.newTab" = "New Tab";
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
"contextMenu.tabColor" = "タブの色";
"contextMenu.clearColor" = "色をクリア";
"contextMenu.chooseCustomColor" = "カスタムカラーを選択…";
"tabBar.newTab" = "新規タブ";
Loading