diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b0863152..1864cb3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/AppDelegate+UITestHarnesses.swift b/Sources/AppDelegate+UITestHarnesses.swift index fb3b1df91..4e12fea79 100644 --- a/Sources/AppDelegate+UITestHarnesses.swift +++ b/Sources/AppDelegate+UITestHarnesses.swift @@ -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 { @@ -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"]) diff --git a/programaUITests/BonsplitTabDragUITests.swift b/programaUITests/BonsplitTabDragUITests.swift index e36ff5d8b..8d7aa29fc 100644 --- a/programaUITests/BonsplitTabDragUITests.swift +++ b/programaUITests/BonsplitTabDragUITests.swift @@ -418,6 +418,77 @@ 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 @@ -425,6 +496,8 @@ final class BonsplitTabDragUITests: XCTestCase { private func launchConfiguredApp( startWithHiddenSidebar: Bool = false, + splitPanes: Bool = false, + singleTab: Bool = false, presentationMode: WorkspacePresentationMode = .minimal ) -> (XCUIApplication, String) { let app = XCUIApplication() @@ -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() diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 27fe911a2..dfde4aeab 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -558,12 +558,14 @@ struct PaneContainerView: 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 { @@ -592,6 +594,23 @@ struct PaneContainerView: 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 @@ -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() + } + } +} diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index fbe1639b1..be17e61fb 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -3,3 +3,4 @@ "contextMenu.tabColor" = "Tab Color"; "contextMenu.clearColor" = "Clear Color"; "contextMenu.chooseCustomColor" = "Choose Custom Color…"; +"tabBar.newTab" = "New Tab"; diff --git a/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index eb9a47bc7..be2ad4bb3 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/vendor/bonsplit/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -3,3 +3,4 @@ "contextMenu.tabColor" = "タブの色"; "contextMenu.clearColor" = "色をクリア"; "contextMenu.chooseCustomColor" = "カスタムカラーを選択…"; +"tabBar.newTab" = "新規タブ";