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
8 changes: 7 additions & 1 deletion Sources/Bonsplit/Internal/Models/TabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -79,6 +82,7 @@ struct TabItem: Identifiable, Hashable, Codable {
case isAudioMuted
case isAudioPlaying
case isPinned
case showsRemoteIndicator
}

init(from decoder: Decoder) throws {
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand Down
8 changes: 6 additions & 2 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ struct TabContextMenuState {
let isFullWidthTabMode: Bool
let hasSplits: Bool
let shortcuts: [TabContextAction: KeyboardShortcut]
var canDisconnectRemote: Bool = false

var canMarkAsUnread: Bool {
!isUnread
Expand Down Expand Up @@ -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
Expand All @@ -965,6 +967,7 @@ struct TabContextMenuState {
self.isFullWidthTabMode = isFullWidthTabMode
self.hasSplits = hasSplits
self.shortcuts = shortcuts
self.canDisconnectRemote = canDisconnectRemote
}

@MainActor
Expand Down Expand Up @@ -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
)
}
}
Expand Down
27 changes: 27 additions & 0 deletions Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ", ")
}
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 22 additions & 6 deletions Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion Sources/Bonsplit/Public/Types/Tab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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
}
}
1 change: 1 addition & 0 deletions Sources/Bonsplit/Public/Types/TabContextAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum TabContextAction: String, CaseIterable, Sendable {
case markAsUnread
case toggleZoom
case toggleFullWidthTab
case disconnectRemote
case forkConversation
case forkConversationRight
case forkConversationLeft
Expand Down
2 changes: 2 additions & 0 deletions Sources/Bonsplit/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 2 additions & 0 deletions Sources/Bonsplit/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@
"tabContext.forkConversation.bottom" = "下分割";
"tabContext.forkConversation.newTab" = "新規タブ";
"tabContext.forkConversation.newWorkspace" = "新規ワークスペース";
"tabContext.disconnectRemote" = "SSH接続を解除";
"tabContext.remoteConnectedAccessibility" = "SSH接続中";
53 changes: 53 additions & 0 deletions Tests/BonsplitTests/BonsplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading