Revert "Merge pull request #239 from manaflow-ai/issue-151-ssh-remote-port-proxying"

This reverts commit 78e4bd32ba, reversing
changes made to cf75da8f8a.
This commit is contained in:
Lawrence Chen 2026-03-12 14:45:58 -07:00
parent 78e4bd32ba
commit f7cbbad434
60 changed files with 1250 additions and 17140 deletions

View file

@ -74,47 +74,6 @@ func cmuxAccentColor() -> Color {
Color(nsColor: cmuxAccentNSColor())
}
struct SidebarRemoteErrorCopyEntry: Equatable {
let workspaceTitle: String
let target: String
let detail: String
}
enum SidebarRemoteErrorCopySupport {
static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1 {
return String(localized: "contextMenu.copyError", defaultValue: "Copy Error")
}
return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors")
}
static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1, let entry = entries.first {
return "SSH error (\(entry.target)): \(entry.detail)"
}
return entries.enumerated().map { index, entry in
"\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)"
}.joined(separator: "\n")
}
static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("SSH error") else { return nil }
if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) {
return (String(match.1), String(match.2))
}
if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) {
guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil }
return (fallbackTarget, String(match.1))
}
return nil
}
}
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
cmuxAccentNSColor(for: colorScheme)
}
@ -1970,7 +1929,6 @@ struct ContentView: View {
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
@ -7338,7 +7296,6 @@ struct VerticalTabsSidebar: View {
#endif
draggedTabId = nil
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func debugShortSidebarTabId(_ id: UUID?) -> String {
@ -9532,7 +9489,6 @@ private struct TabItemView: View, Equatable {
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@ -9635,84 +9591,12 @@ private struct TabItemView: View, Equatable {
)
}
private var remoteWorkspaceSidebarText: String? {
guard tab.hasActiveRemoteTerminalSessions else { return nil }
let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedTarget, !trimmedTarget.isEmpty {
return trimmedTarget
}
return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace")
}
private var copyableSidebarSSHError: String? {
let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty {
let target = tab.remoteDisplayTarget ?? "unknown"
return "SSH error (\(target)): \(trimmedDetail)"
}
if let statusValue = tab.statusEntries["remote.error"]?.value
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusValue.isEmpty {
return statusValue
}
return nil
}
private var remoteConnectionStatusText: String {
switch tab.remoteConnectionState {
case .connected:
return String(localized: "remote.status.connected", defaultValue: "Connected")
case .connecting:
return String(localized: "remote.status.connecting", defaultValue: "Connecting")
case .error:
return String(localized: "remote.status.error", defaultValue: "Error")
case .disconnected:
return String(localized: "remote.status.disconnected", defaultValue: "Disconnected")
}
}
@ViewBuilder
private var remoteWorkspaceSection: some View {
if sidebarShowSSH, let remoteWorkspaceSidebarText {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH"))
.font(.system(size: 9, weight: .semibold))
.foregroundColor(activeSecondaryColor(0.62))
.textCase(.uppercase)
HStack(spacing: 6) {
Text(remoteWorkspaceSidebarText)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.8))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(remoteConnectionStatusText)
.font(.system(size: 9, weight: .medium))
.foregroundColor(activeSecondaryColor(0.58))
.lineLimit(1)
}
}
.padding(.top, latestNotificationText == nil ? 1 : 2)
.safeHelp(remoteStateHelpText)
}
}
private func copyTextToPasteboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
var body: some View {
let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace")
let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.")
let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up")
let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down")
let latestNotificationSubtitle = latestNotificationText
let effectiveSubtitle = latestNotificationSubtitle
let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest)
? tab.sidebarOrderedPanelIds()
: nil
@ -9816,7 +9700,7 @@ private struct TabItemView: View, Equatable {
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
}
if let subtitle = effectiveSubtitle {
if let subtitle = latestNotificationSubtitle {
Text(subtitle)
.font(.system(size: 10))
.foregroundColor(activeSecondaryColor(0.8))
@ -9825,8 +9709,6 @@ private struct TabItemView: View, Equatable {
.multilineTextAlignment(.leading)
}
remoteWorkspaceSection
if sidebarShowMetadata {
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
@ -10086,16 +9968,6 @@ private struct TabItemView: View, Equatable {
let isMulti = targetIds.count > 1
let tabColorPalette = WorkspaceTabColorSettings.palette()
let shouldPin = !tab.isPinned
let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) }
let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace }
let reconnectLabel = contextMenuLabel(
multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"),
single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"),
isMulti: isMulti)
let disconnectLabel = contextMenuLabel(
multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"),
single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"),
isMulti: isMulti)
let pinLabel = shouldPin
? contextMenuLabel(
multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"),
@ -10145,24 +10017,6 @@ private struct TabItemView: View, Equatable {
}
}
if !remoteTargetWorkspaces.isEmpty {
Divider()
Button(reconnectLabel) {
for workspace in remoteTargetWorkspaces {
workspace.reconnectRemoteConnection()
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting })
Button(disconnectLabel) {
for workspace in remoteTargetWorkspaces {
workspace.disconnectRemoteConnection(clearConfiguration: false)
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected })
}
Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) {
if tab.customColor != nil {
Button {
@ -10195,12 +10049,6 @@ private struct TabItemView: View, Equatable {
}
}
if let copyableSidebarSSHError {
Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) {
copyTextToPasteboard(copyableSidebarSSHError)
}
}
Divider()
Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) {
@ -10476,62 +10324,6 @@ private struct TabItemView: View, Equatable {
}
}
private var remoteStateHelpText: String {
let target = tab.remoteDisplayTarget ?? String(
localized: "sidebar.remote.help.targetFallback",
defaultValue: "remote host"
)
let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
switch tab.remoteConnectionState {
case .connected:
return String(
format: String(
localized: "sidebar.remote.help.connected",
defaultValue: "SSH connected to %@"
),
locale: .current,
target
)
case .connecting:
return String(
format: String(
localized: "sidebar.remote.help.connecting",
defaultValue: "SSH connecting to %@"
),
locale: .current,
target
)
case .error:
if let detail, !detail.isEmpty {
return String(
format: String(
localized: "sidebar.remote.help.errorWithDetail",
defaultValue: "SSH error for %@: %@"
),
locale: .current,
target,
detail
)
}
return String(
format: String(
localized: "sidebar.remote.help.error",
defaultValue: "SSH error for %@"
),
locale: .current,
target
)
case .disconnected:
return String(
format: String(
localized: "sidebar.remote.help.disconnected",
defaultValue: "SSH disconnected from %@"
),
locale: .current,
target
)
}
}
private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) {
guard let app = AppDelegate.shared else { return }
let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil }
@ -10733,18 +10525,6 @@ private struct TabItemView: View, Equatable {
}
}
private func shortenPath(_ path: String, home: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return path }
if trimmed == home {
return "~"
}
if trimmed.hasPrefix(home + "/") {
return "~" + trimmed.dropFirst(home.count)
}
return trimmed
}
private struct PullRequestStatusIcon: View {
let status: SidebarPullRequestStatus
let color: Color

View file

@ -2333,8 +2333,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let surfaceContext: ghostty_surface_context_e
private let configTemplate: ghostty_surface_config_s?
private let workingDirectory: String?
private let initialCommand: String?
private let initialEnvironmentOverrides: [String: String]
private let additionalEnvironment: [String: String]
let hostedView: GhosttySurfaceScrollView
private let surfaceView: GhosttyNSView
private var lastPixelWidth: UInt32 = 0
@ -2402,8 +2401,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
context: ghostty_surface_context_e,
configTemplate: ghostty_surface_config_s?,
workingDirectory: String? = nil,
initialCommand: String? = nil,
initialEnvironmentOverrides: [String: String] = [:],
additionalEnvironment: [String: String] = [:]
) {
self.id = UUID()
@ -2411,12 +2408,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
self.surfaceContext = context
self.configTemplate = configTemplate
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
self.additionalEnvironment = additionalEnvironment
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
// has non-zero bounds and the renderer can initialize without presenting a blank/stretched
// intermediate frame on the first real resize.
@ -2434,25 +2426,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceView.tabId = newTabId
}
private static func mergedNormalizedEnvironment(
base: [String: String],
overrides: [String: String]
) -> [String: String] {
var merged: [String: String] = [:]
merged.reserveCapacity(base.count + overrides.count)
for (rawKey, value) in base {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
return merged
}
func isAttached(to view: GhosttyNSView) -> Bool {
attachedView === view && surface != nil
}
@ -2811,10 +2784,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path,
!bundledCLIPath.isEmpty {
env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath
}
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
}
@ -2882,8 +2851,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
if !initialEnvironmentOverrides.isEmpty {
for (key, value) in initialEnvironmentOverrides {
if !additionalEnvironment.isEmpty {
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty {
env[key] = value
}
}
@ -2911,31 +2880,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
let createWithCommandAndWorkingDirectory = { [self] in
if let initialCommand, !initialCommand.isEmpty {
initialCommand.withCString { cCommand in
surfaceConfig.command = cCommand
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
}
} else if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
createWithCommandAndWorkingDirectory()
if surface == nil {
surfaceCallbackContext?.release()
surfaceCallbackContext = nil
@ -3085,7 +3038,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
dlog("forceRefresh: \(id) reason=\(reason) \(viewState)")
#endif
guard let view = attachedView,
let surface,
view.window != nil,
view.bounds.width > 0,
view.bounds.height > 0 else {
@ -5685,7 +5637,6 @@ final class GhosttySurfaceScrollView: NSView {
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var pendingAutomaticFirstResponderApply = false
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
/// Tracks whether keyboard focus should go to the search field or the terminal
@ -6293,7 +6244,7 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
#endif
self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey")
self.applyFirstResponderIfNeeded()
})
windowObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
@ -6316,9 +6267,7 @@ final class GhosttySurfaceScrollView: NSView {
#endif
}
})
if window.isKeyWindow {
scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow")
}
if window.isKeyWindow { applyFirstResponderIfNeeded() }
}
func attachSurface(_ terminalSurface: TerminalSurface) {
@ -6735,7 +6684,7 @@ final class GhosttySurfaceScrollView: NSView {
window.makeFirstResponder(nil)
}
} else {
scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI")
applyFirstResponderIfNeeded()
}
}
@ -6762,7 +6711,7 @@ final class GhosttySurfaceScrollView: NSView {
}
#endif
if active {
scheduleAutomaticFirstResponderApply(reason: "setActive")
applyFirstResponderIfNeeded()
} else {
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
@ -7124,20 +7073,6 @@ final class GhosttySurfaceScrollView: NSView {
return fr === surfaceView || fr.isDescendant(of: surfaceView)
}
private func scheduleAutomaticFirstResponderApply(reason: String) {
guard !pendingAutomaticFirstResponderApply else { return }
pendingAutomaticFirstResponderApply = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingAutomaticFirstResponderApply = false
#if DEBUG
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)")
#endif
self.applyFirstResponderIfNeeded()
}
}
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#if DEBUG
@ -7713,15 +7648,35 @@ final class GhosttySurfaceScrollView: NSView {
/// regions such as scrollbar space) when telling libghostty the terminal size.
@discardableResult
private func synchronizeCoreSurface() -> Bool {
// Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight
// over terminal columns during split churn. The width can flap by one scrollbar gutter,
// which redraws the shell prompt multiple times on Cmd+D. Favor stable columns.
let width = max(0, scrollView.contentSize.width)
let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth())
let height = surfaceView.frame.height
guard width > 0, height > 0 else { return false }
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
}
/// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller.
private func overlayScrollbarInsetWidth() -> CGFloat {
guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 }
// If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction.
let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width)
if alreadyReserved > 0.5 { return 0 }
let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)
guard let verticalScroller = scrollView.verticalScroller else { return fallback }
let measuredWidth = verticalScroller.frame.width
if measuredWidth > 0 {
return max(measuredWidth, fallback)
}
let controlSizeWidth = NSScroller.scrollerWidth(
for: verticalScroller.controlSize,
scrollerStyle: .overlay
)
return max(controlSizeWidth, fallback)
}
private func updateNotificationRingPath() {
updateOverlayRingPath(
layer: notificationRingLayer,
@ -8235,12 +8190,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
}
let portalExpectedSurfaceId = terminalSurface.id
let portalExpectedGeneration = terminalSurface.portalBindingGeneration()
func portalBindingStillLive() -> Bool {
terminalSurface.canAcceptPortalBinding(
expectedSurfaceId: portalExpectedSurfaceId,
expectedGeneration: portalExpectedGeneration
)
}
let forwardedDropZone = isVisibleInUI ? paneDropZone : nil
#if DEBUG
if coordinator.lastPaneDropZone != paneDropZone {
@ -8279,7 +8228,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
reason: "didMoveToWindow"
) else { return }
guard host.window != nil else { return }
guard portalBindingStillLive() else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
@ -8303,7 +8251,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
bounds: host.bounds,
reason: "geometryChanged"
) else { return }
guard portalBindingStillLive() else { return }
let hostId = ObjectIdentifier(host)
if host.window != nil,
(coordinator.lastBoundHostId != hostId ||
@ -8333,7 +8280,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
}
if host.window != nil, hostOwnsPortalNow {
let portalBindingLive = portalBindingStillLive()
let hostId = ObjectIdentifier(host)
let geometryRevision = host.geometryRevision
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
@ -8344,7 +8290,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
previousDesiredPortalZPriority != portalZPriority
if portalBindingLive && shouldBindNow {
if shouldBindNow {
#if DEBUG
if portalEntryMissing {
dlog(
@ -8364,11 +8310,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
coordinator.lastBoundHostId = hostId
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
} else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
} else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else if hostOwnsPortalNow, portalBindingStillLive() {
} else if hostOwnsPortalNow {
// Bind is deferred until host moves into a window. Update the
// existing portal entry's visibleInUI now so that any portal sync
// that runs before the deferred bind completes won't hide the view.
@ -8398,7 +8344,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
isBoundToCurrentHost: isBoundToCurrentHost
)
if portalBindingStillLive() && shouldApplyImmediateHostedState {
if shouldApplyImmediateHostedState {
hostedView.setVisibleInUI(isVisibleInUI)
hostedView.setActive(isActive)
} else {

View file

@ -3,19 +3,6 @@ import Combine
import WebKit
import AppKit
import Bonsplit
import Network
struct BrowserProxyEndpoint: Equatable {
let host: String
let port: Int
}
struct BrowserRemoteWorkspaceStatus: Equatable {
let target: String
let connectionState: WorkspaceRemoteConnectionState
let heartbeatCount: Int
let lastHeartbeatAt: Date?
}
enum GhosttyBackgroundTheme {
static func clampedOpacity(_ opacity: Double) -> CGFloat {
@ -1268,14 +1255,6 @@ final class BrowserPortalAnchorView: NSView {
@MainActor
final class BrowserPanel: Panel, ObservableObject {
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
private static let remoteLoopbackHosts: Set<String> = [
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]
/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()
@ -1794,8 +1773,6 @@ final class BrowserPanel: Panel, ObservableObject {
private var developerToolsRestoreRetryAttempt: Int = 0
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
private let developerToolsRestoreRetryMaxAttempts: Int = 40
private var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date?
private var developerToolsTransitionTargetVisible: Bool?
@ -2038,24 +2015,15 @@ final class BrowserPanel: Panel, ObservableObject {
return instanceID == webViewInstanceID
}
init(
workspaceId: UUID,
initialURL: URL? = nil,
bypassInsecureHTTPHostOnce: String? = nil,
proxyEndpoint: BrowserProxyEndpoint? = nil,
isRemoteWorkspace: Bool = false
) {
init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
self.remoteProxyEndpoint = proxyEndpoint
self.browserThemeMode = BrowserThemeSettings.mode()
let webView = Self.makeWebView()
self.webView = webView
self.insecureHTTPAlertFactory = { NSAlert() }
let _ = isRemoteWorkspace
applyRemoteProxyConfigurationIfAvailable()
// Set up navigation delegate
let navDelegate = BrowserNavigationDelegate()
@ -2135,40 +2103,6 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) {
guard remoteProxyEndpoint != endpoint else { return }
remoteProxyEndpoint = endpoint
applyRemoteProxyConfigurationIfAvailable()
}
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
guard remoteWorkspaceStatus != status else { return }
remoteWorkspaceStatus = status
}
private func applyRemoteProxyConfigurationIfAvailable() {
guard #available(macOS 14.0, *) else { return }
let store = webView.configuration.websiteDataStore
guard let endpoint = remoteProxyEndpoint else {
store.proxyConfigurations = []
return
}
let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty,
endpoint.port > 0 && endpoint.port <= 65535,
let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else {
store.proxyConfigurations = []
return
}
let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort)
let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint)
let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint)
store.proxyConfigurations = [socks, connect]
}
private func beginDownloadActivity() {
let apply = {
self.activeDownloadCount += 1
@ -2665,7 +2599,6 @@ final class BrowserPanel: Panel, ObservableObject {
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request)
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true
@ -2673,35 +2606,7 @@ final class BrowserPanel: Panel, ObservableObject {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
}
navigationDelegate?.lastAttemptedURL = url
browserLoadRequest(effectiveRequest, in: webView)
}
private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest {
guard remoteProxyEndpoint != nil else { return request }
guard let url = request.url else { return request }
guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request }
var rewrittenRequest = request
rewrittenRequest.url = rewrittenURL
#if DEBUG
dlog(
"browser.remoteProxy.rewrite " +
"panel=\(id.uuidString.prefix(5)) " +
"from=\(url.absoluteString) " +
"to=\(rewrittenURL.absoluteString)"
)
#endif
return rewrittenRequest
}
private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil }
guard remoteLoopbackHosts.contains(host) else { return nil }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.host = remoteLoopbackProxyAliasHost
return components?.url
browserLoadRequest(request, in: webView)
}
/// Navigate with smart URL/search detection
@ -3576,16 +3481,6 @@ extension BrowserPanel {
applyPageZoom(1.0)
}
func currentPageZoomFactor() -> CGFloat {
webView.pageZoom
}
@discardableResult
func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool {
let clamped = max(minPageZoom, min(maxPageZoom, pageZoom))
return applyPageZoom(clamped)
}
/// Take a snapshot of the web view
func takeSnapshot(completion: @escaping (NSImage?) -> Void) {
let config = WKSnapshotConfiguration()
@ -4400,13 +4295,6 @@ extension BrowserPanel {
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
}
func hideBrowserPortalView(source: String) {
BrowserWindowPortalRegistry.hide(
webView: webView,
source: source
)
}
}
#endif

View file

@ -84,45 +84,20 @@ final class TerminalPanel: Panel, ObservableObject {
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: ghostty_surface_config_s? = nil,
workingDirectory: String? = nil,
portOrdinal: Int = 0,
initialCommand: String? = nil,
initialEnvironmentOverrides: [String: String] = [:],
additionalEnvironment: [String: String] = [:]
additionalEnvironment: [String: String] = [:],
portOrdinal: Int = 0
) {
let surface = TerminalSurface(
tabId: workspaceId,
context: context,
configTemplate: configTemplate,
workingDirectory: workingDirectory,
initialCommand: initialCommand,
initialEnvironmentOverrides: Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
additionalEnvironment: additionalEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)
}
private static func mergedNormalizedEnvironment(
base: [String: String],
overrides: [String: String]
) -> [String: String] {
var merged: [String: String] = [:]
merged.reserveCapacity(base.count + overrides.count)
for (rawKey, value) in base {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
return merged
}
func updateTitle(_ newTitle: String) {
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && title != trimmed {

View file

@ -406,18 +406,6 @@ struct SocketControlSettings {
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
if let taggedDebugPath = taggedDebugSocketPath(
bundleIdentifier: bundleIdentifier,
environment: environment
) {
if isTruthy(environment[allowSocketPathOverrideKey]),
let override = environment["CMUX_SOCKET_PATH"],
!override.isEmpty {
return override
}
return taggedDebugPath
}
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
return fallback
}
@ -434,9 +422,6 @@ struct SocketControlSettings {
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
return taggedDebugPath
}
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
@ -469,37 +454,6 @@ struct SocketControlSettings {
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
static func taggedDebugSocketPath(
bundleIdentifier: String?,
environment: [String: String]
) -> String? {
let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") {
let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1))
let slug = suffix
.replacingOccurrences(of: ".", with: "-")
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
if !slug.isEmpty {
return "/tmp/cmux-debug-\(slug).sock"
}
}
let tag = launchTag(environment: environment)?
.lowercased()
.replacingOccurrences(of: ".", with: "-")
.replacingOccurrences(of: "_", with: "-")
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
guard bundleId == baseDebugBundleIdentifier,
let tag,
!tag.isEmpty else {
return nil
}
return "/tmp/cmux-debug-\(tag).sock"
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"

View file

@ -30,20 +30,11 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
var description: String {
switch self {
case .top:
return String(
localized: "workspace.placement.top.description",
defaultValue: "Insert new workspaces at the top of the list."
)
return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.")
case .afterCurrent:
return String(
localized: "workspace.placement.afterCurrent.description",
defaultValue: "Insert new workspaces directly after the active workspace."
)
return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.")
case .end:
return String(
localized: "workspace.placement.end.description",
defaultValue: "Append new workspaces to the bottom of the list."
)
return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.")
}
}
}
@ -81,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return "Solid Fill"
return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill")
}
}
}
@ -741,25 +732,36 @@ class TabManager: ObservableObject {
}
var isFindVisible: Bool {
selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil
if selectedTerminalPanel?.searchState != nil { return true }
if focusedBrowserPanel?.searchState != nil { return true }
return false
}
var canUseSelectionForFind: Bool {
selectedTerminalPanel?.hasSelection() == true
if focusedBrowserPanel != nil { return false }
return selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
if let panel = selectedTerminalPanel {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
if let browser = focusedBrowserPanel {
browser.startFind()
return
}
focusedBrowserPanel?.startFind()
guard let panel = selectedTerminalPanel else {
#if DEBUG
dlog("find.startSearch SKIPPED no selectedTerminalPanel")
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}
func searchSelection() {
@ -767,27 +769,27 @@ class TabManager: ObservableObject {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
if let panel = selectedTerminalPanel {
_ = panel.performBindingAction("search:next")
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findNext()
return
}
focusedBrowserPanel?.findNext()
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
if let panel = selectedTerminalPanel {
_ = panel.performBindingAction("search:previous")
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findPrevious()
return
}
focusedBrowserPanel?.findPrevious()
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
@ -797,26 +799,27 @@ class TabManager: ObservableObject {
}
func hideFind() {
if let panel = selectedTerminalPanel {
panel.searchState = nil
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.hideFind()
return
}
focusedBrowserPanel?.hideFind()
#if DEBUG
dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
selectedTerminalPanel?.searchState = nil
}
@discardableResult
func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
initialTerminalCommand: String? = nil,
initialTerminalEnvironment: [String: String] = [:],
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil,
autoWelcomeIfNeeded: Bool = true
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -824,9 +827,7 @@ class TabManager: ObservableObject {
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig,
initialTerminalCommand: initialTerminalCommand,
initialTerminalEnvironment: initialTerminalEnvironment
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
@ -835,8 +836,17 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
selectedTabId = newWorkspace.id
@ -1152,6 +1162,16 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: insertIndex)
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
@ -1164,16 +1184,6 @@ class TabManager: ObservableObject {
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
@discardableResult
func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool {
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
@ -1259,23 +1269,22 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownAllPanels()
workspace.teardownRemoteConnection()
unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels()
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
tabs.remove(at: index)
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
}
@ -1284,6 +1293,7 @@ class TabManager: ObservableObject {
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId)
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
@ -1345,9 +1355,13 @@ class TabManager: ObservableObject {
let count = plan.panelIds.count
let titleLines = plan.titles.map { "\($0)" }.joined(separator: "\n")
let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
let message = if count == 1 {
String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)")
} else {
String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)")
}
guard confirmClose(
title: "Close other tabs?",
title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
message: message,
acceptCmdD: false
) else { return }
@ -1387,8 +1401,8 @@ class TabManager: ObservableObject {
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
@ -1449,15 +1463,15 @@ class TabManager: ObservableObject {
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return "Untitled Tab"
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
acceptCmdD: willCloseWindow
) {
return
@ -1498,8 +1512,8 @@ class TabManager: ObservableObject {
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
@ -1507,7 +1521,7 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
@ -1539,8 +1553,8 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
@ -1578,8 +1592,8 @@ class TabManager: ObservableObject {
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else { return }
}
@ -1846,28 +1860,32 @@ class TabManager: ObservableObject {
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
@discardableResult
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true)
}
@discardableResult
private func dismissNotificationIfActive(
tabId: UUID,
surfaceId: UUID?,
triggerFlash: Bool
) -> Bool {
guard selectedTabId == tabId else { return false }
guard AppFocusState.isAppActive() else { return false }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
if let panelId = surfaceId,
if triggerFlash,
let panelId = surfaceId,
let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
@ -2166,9 +2184,24 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=terminal dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
#if DEBUG
dlog(
"split.create.result kind=terminal dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
}
/// Create a new browser split from the currently focused panel.
@ -2177,14 +2210,30 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return nil }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=browser dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
return newBrowserSplit(
let createdPanelId = newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
#if DEBUG
dlog(
"split.create.result kind=browser dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
return createdPanelId
}
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
@ -2285,12 +2334,21 @@ class TabManager: ObservableObject {
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
let createdPanel = tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
focus: focus
)?.id
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.newSurface result dir=\(directionLabel) " +
"tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " +
"created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
return createdPanel
}
/// Move focus in the specified direction
@ -2891,7 +2949,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}
@ -3869,6 +3927,15 @@ enum SplitDirection {
var insertFirst: Bool {
self == .left || self == .up
}
var debugLabel: String {
switch self {
case .left: return "left"
case .right: return "right"
case .up: return "up"
case .down: return "down"
}
}
}
/// Resize direction for backwards compatibility

File diff suppressed because it is too large Load diff

View file

@ -94,7 +94,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
let text: String
if let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) {
let title = tab.title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)"
} else {
text = "Cmd: —"

File diff suppressed because it is too large Load diff

View file

@ -3086,7 +3086,6 @@ struct SettingsView: View {
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey)
private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@ -3698,17 +3697,6 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"),
subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.")
) {
Toggle("", isOn: $sidebarShowSSH)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"),
subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.")
@ -4394,7 +4382,6 @@ struct SettingsView: View {
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
sidebarShowSSH = true
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true