diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 50b2c12a..5428ce4d 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -912,7 +912,7 @@ struct TabContextMenuState { let canMoveToNewWorkspace: Bool let canMoveToLeftPane: Bool let canMoveToRightPane: Bool - let canForkConversation: Bool + var canForkConversation: Bool let forkConversationDefaultAction: TabContextAction let isZoomed: Bool let hasSplits: Bool @@ -1349,6 +1349,9 @@ struct TabBarView: View { moveDestinationsProvider: { controller.tabContextMoveDestinationsProvider?(TabID(id: tab.id), pane.id) ?? [] }, + forkConversationOpenAvailabilityProvider: { + controller.tabContextForkConversationOpenAvailabilityProvider?(TabID(id: tab.id), pane.id) + }, onSelect: { // Tab selection must be instant. Animating this transaction causes the pane // content (often swapped via opacity) to crossfade, which is undesirable for diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 1b2f74fc..a3f49bde 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -229,6 +229,7 @@ struct TabItemView: View { let allowsClose: Bool let contextMenuState: TabContextMenuState let moveDestinationsProvider: () -> [TabContextMoveDestination] + let forkConversationOpenAvailabilityProvider: () -> Bool? let onSelect: () -> Void let onClose: (TabCloseRequestSource) -> Void let onZoomToggle: () -> Void @@ -281,7 +282,8 @@ struct TabItemView: View { snapshot: TabContextMenuSnapshot( tabId: tab.id, state: contextMenuState, - moveDestinationsProvider: moveDestinationsProvider + moveDestinationsProvider: moveDestinationsProvider, + forkConversationOpenAvailabilityProvider: forkConversationOpenAvailabilityProvider ), onContextAction: onContextAction, onMoveDestination: onMoveDestination @@ -1112,6 +1114,7 @@ struct TabContextMenuSnapshot { let tabId: UUID let state: TabContextMenuState let moveDestinationsProvider: () -> [TabContextMoveDestination] + let forkConversationOpenAvailabilityProvider: () -> Bool? } final class TabContextMenuActionTarget: NSObject { @@ -1137,7 +1140,10 @@ enum TabContextMenuBuilder { snapshot: TabContextMenuSnapshot, target: TabContextMenuActionTarget ) -> NSMenu { - let state = snapshot.state + var state = snapshot.state + let canForkConversationAtOpen = snapshot.forkConversationOpenAvailabilityProvider() + let forkConversationEnabled = canForkConversationAtOpen ?? state.canForkConversation + state.canForkConversation = state.canForkConversation || forkConversationEnabled let menu = NSMenu() menu.autoenablesItems = false @@ -1212,11 +1218,16 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation", defaultValue: "Fork Conversation"), action: .forkConversation, + enabled: forkConversationEnabled, state: state, target: target, to: menu ) - menu.addItem(forkConversationSubmenuItem(state: state, target: target)) + menu.addItem(forkConversationSubmenuItem( + state: state, + target: target, + enabled: forkConversationEnabled + )) } menu.addItem(.separator()) @@ -1359,7 +1370,8 @@ enum TabContextMenuBuilder { private static func forkConversationSubmenuItem( state: TabContextMenuState, - target: TabContextMenuActionTarget + target: TabContextMenuActionTarget, + enabled: Bool ) -> NSMenuItem { let item = NSMenuItem( title: localized("tabContext.forkConversationTo", defaultValue: "Fork Conversation To"), @@ -1375,6 +1387,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.right", defaultValue: "Right Split"), action: .forkConversationRight, + enabled: enabled, state: state, target: target, to: submenu, @@ -1383,6 +1396,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.left", defaultValue: "Left Split"), action: .forkConversationLeft, + enabled: enabled, state: state, target: target, to: submenu, @@ -1391,6 +1405,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.top", defaultValue: "Top Split"), action: .forkConversationTop, + enabled: enabled, state: state, target: target, to: submenu, @@ -1399,6 +1414,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.bottom", defaultValue: "Bottom Split"), action: .forkConversationBottom, + enabled: enabled, state: state, target: target, to: submenu, @@ -1408,6 +1424,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.newTab", defaultValue: "New Tab"), action: .forkConversationNewTab, + enabled: enabled, state: state, target: target, to: submenu, @@ -1416,6 +1433,7 @@ enum TabContextMenuBuilder { addAction( title: localized("tabContext.forkConversation.newWorkspace", defaultValue: "New Workspace"), action: .forkConversationNewWorkspace, + enabled: enabled, state: state, target: target, to: submenu, @@ -1423,7 +1441,7 @@ enum TabContextMenuBuilder { ) item.submenu = submenu - item.isEnabled = true + item.isEnabled = enabled return item } diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 44dc9b69..5713291e 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -82,6 +82,9 @@ public final class BonsplitController { /// session). Return `true` to enable the item, `false` (or omit the provider) to hide it. @ObservationIgnored public var tabContextForkConversationAvailabilityProvider: ((TabID, PaneID) -> Bool)? + /// Host-provided check evaluated only when the tab context menu is opening. + @ObservationIgnored public var tabContextForkConversationOpenAvailabilityProvider: ((TabID, PaneID) -> Bool)? + /// Host-provided default destination for the tab context menu's primary "Fork /// Conversation" action. Return a destination-specific fork action; invalid values /// fall back to `.forkConversationRight`. diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 630da10f..b0561194 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1673,7 +1673,8 @@ final class BonsplitTests: XCTestCase { return [ TabContextMoveDestination(id: "workspace:abc", title: "Workspace A", isEnabled: false) ] - } + }, + forkConversationOpenAvailabilityProvider: { nil } ) let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) @@ -1720,7 +1721,8 @@ final class BonsplitTests: XCTestCase { hasSplits: false, shortcuts: [:] ), - moveDestinationsProvider: { [] } + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { nil } ) let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) @@ -1754,7 +1756,8 @@ final class BonsplitTests: XCTestCase { hasSplits: false, shortcuts: [:] ), - moveDestinationsProvider: { [] } + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { nil } ) let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) @@ -1790,7 +1793,8 @@ final class BonsplitTests: XCTestCase { let snapshot = TabContextMenuSnapshot( tabId: UUID(), state: state, - moveDestinationsProvider: { [] } + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { nil } ) let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) @@ -1811,6 +1815,45 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(selectedAction, .forkConversationNewTab) } + @MainActor + func testTabContextMenuBuilderKeepsForkConversationVisibleWhenOpenAvailabilityIsRefreshing() throws { + let target = TabContextMenuActionTarget() + let state = TabContextMenuState( + isPinned: false, + isUnread: false, + isBrowser: false, + isAudioMuted: false, + isTerminal: true, + hasCustomTitle: false, + canCloseToLeft: true, + canCloseToRight: true, + canCloseOthers: true, + canMoveToNewWorkspace: false, + canMoveToLeftPane: false, + canMoveToRightPane: false, + canForkConversation: true, + forkConversationDefaultAction: .forkConversationLeft, + isZoomed: false, + hasSplits: false, + shortcuts: [:] + ) + let snapshot = TabContextMenuSnapshot( + tabId: UUID(), + state: state, + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { false } + ) + + let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) + let forkItem = try XCTUnwrap(menu.items.first { $0.title == "Fork Conversation" }) + let forkSubmenuItem = try XCTUnwrap(menu.items.first { $0.title == "Fork Conversation To" }) + let destinationItems = try XCTUnwrap(forkSubmenuItem.submenu?.items.filter { !$0.isSeparatorItem }) + + XCTAssertFalse(forkItem.isEnabled) + XCTAssertFalse(forkSubmenuItem.isEnabled) + XCTAssertEqual(destinationItems.map(\.isEnabled), Array(repeating: false, count: 6)) + } + @MainActor func testDoubleClickingEmptyTrailingTabBarSpaceRequestsNewTerminalTab() { let appearance = BonsplitConfiguration.Appearance()