diff --git a/Sources/Bonsplit/Internal/Models/TabItem.swift b/Sources/Bonsplit/Internal/Models/TabItem.swift index 1ca1e18a..e4eefb1d 100644 --- a/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -29,6 +29,7 @@ struct TabItem: Identifiable, Hashable, Codable { /// consumer-defined meaning, e.g. a browser page playing sound). var isAudioPlaying: Bool var isPinned: Bool + var showsRemoteIndicator: Bool init( id: UUID = UUID(), @@ -42,7 +43,8 @@ struct TabItem: Identifiable, Hashable, Codable { isLoading: Bool = false, isAudioMuted: Bool = false, isAudioPlaying: Bool = false, - isPinned: Bool = false + isPinned: Bool = false, + showsRemoteIndicator: Bool = false ) { self.id = id self.title = title @@ -56,6 +58,7 @@ struct TabItem: Identifiable, Hashable, Codable { self.isAudioMuted = isAudioMuted self.isAudioPlaying = isAudioPlaying self.isPinned = isPinned + self.showsRemoteIndicator = showsRemoteIndicator } func hash(into hasher: inout Hasher) { @@ -79,6 +82,7 @@ struct TabItem: Identifiable, Hashable, Codable { case isAudioMuted case isAudioPlaying case isPinned + case showsRemoteIndicator } init(from decoder: Decoder) throws { @@ -95,6 +99,7 @@ struct TabItem: Identifiable, Hashable, Codable { self.isAudioMuted = try c.decodeIfPresent(Bool.self, forKey: .isAudioMuted) ?? false self.isAudioPlaying = try c.decodeIfPresent(Bool.self, forKey: .isAudioPlaying) ?? false self.isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + self.showsRemoteIndicator = try c.decodeIfPresent(Bool.self, forKey: .showsRemoteIndicator) ?? false } func encode(to encoder: Encoder) throws { @@ -111,6 +116,7 @@ struct TabItem: Identifiable, Hashable, Codable { try c.encode(isAudioMuted, forKey: .isAudioMuted) try c.encode(isAudioPlaying, forKey: .isAudioPlaying) try c.encode(isPinned, forKey: .isPinned) + try c.encode(showsRemoteIndicator, forKey: .showsRemoteIndicator) } } diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index c7ebda4b..e6eb1ec9 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -918,6 +918,7 @@ struct TabContextMenuState { let isFullWidthTabMode: Bool let hasSplits: Bool let shortcuts: [TabContextAction: KeyboardShortcut] + var canDisconnectRemote: Bool = false var canMarkAsUnread: Bool { !isUnread @@ -945,7 +946,8 @@ struct TabContextMenuState { isZoomed: Bool, isFullWidthTabMode: Bool = false, hasSplits: Bool, - shortcuts: [TabContextAction: KeyboardShortcut] + shortcuts: [TabContextAction: KeyboardShortcut], + canDisconnectRemote: Bool = false ) { self.isPinned = isPinned self.isUnread = isUnread @@ -965,6 +967,7 @@ struct TabContextMenuState { self.isFullWidthTabMode = isFullWidthTabMode self.hasSplits = hasSplits self.shortcuts = shortcuts + self.canDisconnectRemote = canDisconnectRemote } @MainActor @@ -1006,7 +1009,8 @@ struct TabContextMenuState { isZoomed: splitViewController.zoomedPaneId == pane.id, isFullWidthTabMode: pane.isFullWidthTabMode, hasSplits: splitViewController.rootNode.allPaneIds.count > 1, - shortcuts: controller.contextMenuShortcuts + shortcuts: controller.contextMenuShortcuts, + canDisconnectRemote: controller.tabContextDisconnectRemoteAvailabilityProvider?(TabID(id: tab.id), pane.id) ?? false ) } } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 7fbe3758..98fdf6f1 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -342,6 +342,19 @@ struct TabItemView: View { ) .saturation(saturation) + if tab.showsRemoteIndicator { + Image(systemName: "network") + .font(.system(size: accessoryFontSize, weight: .semibold)) + .foregroundStyle( + (isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance)) + .opacity(0.78) + ) + .saturation(saturation) + .accessibilityHidden(true) + } + // Chrome/Safari-style audio affordance: a speaker glyph appears // when the tab is producing audible audio (click to mute) or has // been muted (click to unmute). Reuses the existing @@ -754,6 +767,9 @@ struct TabItemView: View { if tab.isAudioMuted { parts.append(Bundle.module.localizedString(forKey: "tabContext.audioMutedAccessibility", value: "Muted", table: nil)) } + if tab.showsRemoteIndicator { + parts.append(Bundle.module.localizedString(forKey: "tabContext.remoteConnectedAccessibility", value: "Connected over SSH", table: nil)) + } if showsZoomIndicator { parts.append("Zoomed") } return parts.joined(separator: ", ") } @@ -1274,6 +1290,17 @@ enum TabContextMenuBuilder { ) } + if state.canDisconnectRemote { + menu.addItem(.separator()) + addAction( + title: localized("tabContext.disconnectRemote", defaultValue: "Disconnect SSH"), + action: .disconnectRemote, + state: state, + target: target, + to: menu + ) + } + menu.addItem(.separator()) if state.hasSplits { diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index e5f768e9..f448f7d2 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -90,6 +90,12 @@ public final class BonsplitController { /// fall back to `.forkConversationRight`. @ObservationIgnored public var tabContextForkConversationDefaultActionProvider: ((TabID, PaneID) -> TabContextAction)? + /// Host-provided synchronous check that decides whether the tab context menu should + /// surface a "Disconnect SSH" action for the tab (e.g. a terminal surface attached to + /// a host-managed remote connection). Return `true` to show the item, `false` (or omit + /// the provider) to hide it. + @ObservationIgnored public var tabContextDisconnectRemoteAvailabilityProvider: ((TabID, PaneID) -> Bool)? + /// Called when the user explicitly requests to close a tab from the tab strip UI. /// Internal host-driven closes should not use this hook. @ObservationIgnored public var onTabCloseRequest: ((_ tabId: TabID, _ paneId: PaneID, _ source: TabCloseRequestSource) -> Void)? @@ -143,6 +149,7 @@ public final class BonsplitController { isAudioMuted: Bool = false, isAudioPlaying: Bool = false, isPinned: Bool = false, + showsRemoteIndicator: Bool = false, inPane pane: PaneID? = nil ) -> TabID? { let tabId = TabID() @@ -158,7 +165,8 @@ public final class BonsplitController { isLoading: isLoading, isAudioMuted: isAudioMuted, isAudioPlaying: isAudioPlaying, - isPinned: isPinned + isPinned: isPinned, + showsRemoteIndicator: showsRemoteIndicator ) let targetPane = pane ?? focusedPaneId ?? PaneID(id: internalController.rootNode.allPaneIds.first!.id) @@ -197,7 +205,8 @@ public final class BonsplitController { isLoading: isLoading, isAudioMuted: isAudioMuted, isAudioPlaying: isAudioPlaying, - isPinned: isPinned + isPinned: isPinned, + showsRemoteIndicator: showsRemoteIndicator ) internalController.addTab(tabItem, toPane: PaneID(id: targetPane.id), atIndex: insertIndex) @@ -261,7 +270,8 @@ public final class BonsplitController { isLoading: Bool? = nil, isAudioMuted: Bool? = nil, isAudioPlaying: Bool? = nil, - isPinned: Bool? = nil + isPinned: Bool? = nil, + showsRemoteIndicator: Bool? = nil ) { guard let (pane, tabIndex) = findTabInternal(tabId) else { return } let currentTab = pane.tabs[tabIndex] @@ -276,7 +286,8 @@ public final class BonsplitController { isLoading.map { currentTab.isLoading != $0 } ?? false || isAudioMuted.map { currentTab.isAudioMuted != $0 } ?? false || isAudioPlaying.map { currentTab.isAudioPlaying != $0 } ?? false || - isPinned.map { currentTab.isPinned != $0 } ?? false + isPinned.map { currentTab.isPinned != $0 } ?? false || + showsRemoteIndicator.map { currentTab.showsRemoteIndicator != $0 } ?? false guard didChange else { return } if let title = title { @@ -312,6 +323,9 @@ public final class BonsplitController { if let isPinned = isPinned { pane.tabs[tabIndex].isPinned = isPinned } + if let showsRemoteIndicator = showsRemoteIndicator { + pane.tabs[tabIndex].showsRemoteIndicator = showsRemoteIndicator + } } /// Close a tab by ID @@ -477,7 +491,8 @@ public final class BonsplitController { isLoading: tab.isLoading, isAudioMuted: tab.isAudioMuted, isAudioPlaying: tab.isAudioPlaying, - isPinned: tab.isPinned + isPinned: tab.isPinned, + showsRemoteIndicator: tab.showsRemoteIndicator ) } else { internalTab = nil @@ -543,7 +558,8 @@ public final class BonsplitController { isLoading: tab.isLoading, isAudioMuted: tab.isAudioMuted, isAudioPlaying: tab.isAudioPlaying, - isPinned: tab.isPinned + isPinned: tab.isPinned, + showsRemoteIndicator: tab.showsRemoteIndicator ) // Perform split with insertion side. diff --git a/Sources/Bonsplit/Public/Types/Tab.swift b/Sources/Bonsplit/Public/Types/Tab.swift index 09becfbc..fac6e9e8 100644 --- a/Sources/Bonsplit/Public/Types/Tab.swift +++ b/Sources/Bonsplit/Public/Types/Tab.swift @@ -22,6 +22,8 @@ public struct Tab: Identifiable, Hashable, Sendable { public let isAudioPlaying: Bool /// Whether the tab is pinned in its pane. public let isPinned: Bool + /// Whether the tab should show a remote-connection indicator (library consumer-defined meaning, e.g. SSH). + public let showsRemoteIndicator: Bool public init( id: TabID = TabID(), @@ -35,7 +37,8 @@ public struct Tab: Identifiable, Hashable, Sendable { isLoading: Bool = false, isAudioMuted: Bool = false, isAudioPlaying: Bool = false, - isPinned: Bool = false + isPinned: Bool = false, + showsRemoteIndicator: Bool = false ) { self.id = id self.title = title @@ -49,6 +52,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.isAudioMuted = isAudioMuted self.isAudioPlaying = isAudioPlaying self.isPinned = isPinned + self.showsRemoteIndicator = showsRemoteIndicator } internal init(from tabItem: TabItem) { @@ -64,5 +68,6 @@ public struct Tab: Identifiable, Hashable, Sendable { self.isAudioMuted = tabItem.isAudioMuted self.isAudioPlaying = tabItem.isAudioPlaying self.isPinned = tabItem.isPinned + self.showsRemoteIndicator = tabItem.showsRemoteIndicator } } diff --git a/Sources/Bonsplit/Public/Types/TabContextAction.swift b/Sources/Bonsplit/Public/Types/TabContextAction.swift index d0cd8f8b..459c5f1b 100644 --- a/Sources/Bonsplit/Public/Types/TabContextAction.swift +++ b/Sources/Bonsplit/Public/Types/TabContextAction.swift @@ -22,6 +22,7 @@ public enum TabContextAction: String, CaseIterable, Sendable { case markAsUnread case toggleZoom case toggleFullWidthTab + case disconnectRemote case forkConversation case forkConversationRight case forkConversationLeft diff --git a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index 8e956239..d2b6d8fd 100644 --- a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -33,3 +33,5 @@ "tabContext.forkConversation.bottom" = "Bottom Split"; "tabContext.forkConversation.newTab" = "New Tab"; "tabContext.forkConversation.newWorkspace" = "New Workspace"; +"tabContext.disconnectRemote" = "Disconnect SSH"; +"tabContext.remoteConnectedAccessibility" = "Connected over SSH"; diff --git a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index ef116a03..1d3c7315 100644 --- a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -33,3 +33,5 @@ "tabContext.forkConversation.bottom" = "下分割"; "tabContext.forkConversation.newTab" = "新規タブ"; "tabContext.forkConversation.newWorkspace" = "新規ワークスペース"; +"tabContext.disconnectRemote" = "SSH接続を解除"; +"tabContext.remoteConnectedAccessibility" = "SSH接続中"; diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 4c365a72..c9d867d1 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -2139,6 +2139,59 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(destinationItems.map(\.isEnabled), Array(repeating: false, count: 6)) } + @MainActor + func testTabContextMenuShowsDisconnectRemoteOnlyWhenAvailable() throws { + let target = TabContextMenuActionTarget() + var selectedAction: TabContextAction? + target.onContextAction = { selectedAction = $0 } + func makeState(canDisconnectRemote: Bool) -> TabContextMenuState { + TabContextMenuState( + isPinned: false, + isUnread: false, + isBrowser: false, + isAudioMuted: false, + isTerminal: true, + hasCustomTitle: false, + canCloseToLeft: false, + canCloseToRight: false, + canCloseOthers: false, + canMoveToNewWorkspace: false, + canMoveToLeftPane: false, + canMoveToRightPane: false, + canForkConversation: false, + forkConversationDefaultAction: .forkConversationRight, + isZoomed: false, + hasSplits: false, + shortcuts: [:], + canDisconnectRemote: canDisconnectRemote + ) + } + + let withoutRemote = TabContextMenuBuilder.makeMenu( + snapshot: TabContextMenuSnapshot( + tabId: UUID(), + state: makeState(canDisconnectRemote: false), + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { true } + ), + target: target + ) + XCTAssertFalse(withoutRemote.items.contains { $0.title == "Disconnect SSH" }) + + let withRemote = TabContextMenuBuilder.makeMenu( + snapshot: TabContextMenuSnapshot( + tabId: UUID(), + state: makeState(canDisconnectRemote: true), + moveDestinationsProvider: { [] }, + forkConversationOpenAvailabilityProvider: { true } + ), + target: target + ) + let disconnectItem = try XCTUnwrap(withRemote.items.first { $0.title == "Disconnect SSH" }) + target.performContextAction(disconnectItem) + XCTAssertEqual(selectedAction, .disconnectRemote) + } + @MainActor func testDoubleClickingEmptyTrailingTabBarSpaceRequestsNewTerminalTab() { let appearance = BonsplitConfiguration.Appearance()