From 1eee5e93fc988609d993732a0ba8fbacbd3f5cbf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 21 Apr 2026 15:34:21 -0700 Subject: [PATCH 1/2] Add sidebar terminal modes --- Resources/Localizable.xcstrings | 70 ++++- Sources/AppDelegate.swift | 2 +- Sources/AppleScriptSupport.swift | 2 +- Sources/CmuxConfig.swift | 102 +++++++- Sources/ContentView.swift | 387 +++++++++++++++++++++------- Sources/GhosttyTerminalView.swift | 35 ++- Sources/RightSidebarPanelView.swift | 20 ++ Sources/TerminalController.swift | 10 +- Sources/Workspace.swift | 152 ++++++++++- cmuxTests/CmuxConfigTests.swift | 44 ++++ 10 files changed, 694 insertions(+), 130 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b1d6c2e865d9..a5b49fb2af7c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -90299,6 +90299,40 @@ } } }, + "leftSidebar.mode.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "leftSidebar.mode.workspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース" + } + } + } + }, "rightSidebar.mode.files": { "extractionState": "manual", "localizations": { @@ -90333,6 +90367,40 @@ } } }, + "rightSidebar.mode.terminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "sidebar.terminal.noWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspace selected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースが選択されていません" + } + } + } + }, "sessionIndex.agent.claude": { "extractionState": "manual", "localizations": { @@ -91059,4 +91127,4 @@ } } } -} \ No newline at end of file +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4d1ff0dee9c9..2f46d7a61dd7 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -6037,7 +6037,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sourceManager: TabManager, sourceWindowId: UUID ) { - guard sourceWorkspace.panels.isEmpty else { return } + guard !sourceWorkspace.hasBonsplitPanels else { return } guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } if sourceManager.tabs.count > 1 { diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift index b90db00aa416..be66d4804718 100644 --- a/Sources/AppleScriptSupport.swift +++ b/Sources/AppleScriptSupport.swift @@ -619,7 +619,7 @@ final class ScriptTerminal: NSObject { return nil } - if workspace.panels.count == 1 { + if workspace.bonsplitPanelCount == 1 { if state.tabManager.tabs.count > 1 { state.tabManager.closeWorkspace(workspace) return nil diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4fe7f8ec0b77..93a90c13c30a 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -4,6 +4,87 @@ import Foundation struct CmuxConfigFile: Codable, Sendable { var commands: [CmuxCommandDefinition] + var sidebar: CmuxSidebarDefinition? + + init(commands: [CmuxCommandDefinition] = [], sidebar: CmuxSidebarDefinition? = nil) { + self.commands = commands + self.sidebar = sidebar + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commands = try container.decodeIfPresent([CmuxCommandDefinition].self, forKey: .commands) ?? [] + sidebar = try container.decodeIfPresent(CmuxSidebarDefinition.self, forKey: .sidebar) + } +} + +enum SidebarTerminalPlacement: String, Codable, Sendable { + case left + case right +} + +struct CmuxSidebarTerminalDefinition: Codable, Sendable, Equatable { + var title: String? + var command: String? + var cwd: String? + + init(title: String? = nil, command: String? = nil, cwd: String? = nil) { + self.title = title + self.command = command + self.cwd = cwd + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .title)) + command = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .command)) + cwd = Self.normalizedOptionalString(try container.decodeIfPresent(String.self, forKey: .cwd)) + } + + private static func normalizedOptionalString(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +struct CmuxSidebarSideDefinition: Codable, Sendable { + var terminal: CmuxSidebarTerminalDefinition? +} + +struct CmuxSidebarDefinition: Codable, Sendable { + var left: CmuxSidebarSideDefinition? + var right: CmuxSidebarSideDefinition? + + var runtimeConfiguration: CmuxSidebarConfiguration { + CmuxSidebarConfiguration( + leftTerminal: left?.terminal, + rightTerminal: right?.terminal + ) + } +} + +struct CmuxSidebarConfiguration: Sendable, Equatable { + var leftTerminal: CmuxSidebarTerminalDefinition? = nil + var rightTerminal: CmuxSidebarTerminalDefinition? = nil + + static let empty = CmuxSidebarConfiguration() + + func terminal(for placement: SidebarTerminalPlacement) -> CmuxSidebarTerminalDefinition? { + switch placement { + case .left: + return leftTerminal + case .right: + return rightTerminal + } + } + + func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration { + CmuxSidebarConfiguration( + leftTerminal: overrides.leftTerminal ?? leftTerminal, + rightTerminal: overrides.rightTerminal ?? rightTerminal + ) + } } struct CmuxCommandDefinition: Codable, Sendable, Identifiable { @@ -259,6 +340,7 @@ enum CmuxSurfaceType: String, Codable, Sendable { @MainActor final class CmuxConfigStore: ObservableObject { @Published private(set) var loadedCommands: [CmuxCommandDefinition] = [] + @Published private(set) var sidebarConfiguration: CmuxSidebarConfiguration = .empty @Published private(set) var configRevision: UInt64 = 0 /// Which config file each command came from, keyed by command id. @@ -353,22 +435,22 @@ final class CmuxConfigStore: ObservableObject { var commands: [CmuxCommandDefinition] = [] var seenNames = Set() var sourcePaths: [String: String] = [:] + let localConfig = localConfigPath.flatMap { parseConfig(at: $0) } + let globalConfig = parseConfig(at: globalConfigPath) // Local config takes precedence - if let localPath = localConfigPath { - if let localConfig = parseConfig(at: localPath) { - for command in localConfig.commands { - if !seenNames.contains(command.name) { - commands.append(command) - seenNames.insert(command.name) - sourcePaths[command.id] = localPath - } + if let localPath = localConfigPath, let localConfig { + for command in localConfig.commands { + if !seenNames.contains(command.name) { + commands.append(command) + seenNames.insert(command.name) + sourcePaths[command.id] = localPath } } } // Global config fills in the rest - if let globalConfig = parseConfig(at: globalConfigPath) { + if let globalConfig { for command in globalConfig.commands { if !seenNames.contains(command.name) { commands.append(command) @@ -380,6 +462,8 @@ final class CmuxConfigStore: ObservableObject { loadedCommands = commands commandSourcePaths = sourcePaths + sidebarConfiguration = (globalConfig?.sidebar?.runtimeConfiguration ?? .empty) + .merging(overrides: localConfig?.sidebar?.runtimeConfiguration ?? .empty) configRevision &+= 1 } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 698df576a5fc..978f5ffc1876 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10366,12 +10366,171 @@ private struct SidebarTabItemPresentationSnapshot: Equatable { let showsModifierShortcutHints: Bool } +private enum LeftSidebarMode: String, CaseIterable { + case workspaces + case terminal + + var label: String { + switch self { + case .workspaces: + return String(localized: "leftSidebar.mode.workspaces", defaultValue: "Workspaces") + case .terminal: + return String(localized: "leftSidebar.mode.terminal", defaultValue: "Terminal") + } + } + + var symbolName: String { + switch self { + case .workspaces: + return "rectangle.stack" + case .terminal: + return "terminal.fill" + } + } +} + +private struct LeftSidebarModeBar: View { + let selectedMode: LeftSidebarMode + let onSelect: (LeftSidebarMode) -> Void + + var body: some View { + HStack(spacing: 4) { + ForEach(LeftSidebarMode.allCases, id: \.rawValue) { mode in + LeftSidebarModeBarButton( + mode: mode, + isSelected: selectedMode == mode + ) { + onSelect(mode) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct LeftSidebarModeBarButton: View { + let mode: LeftSidebarMode + let isSelected: Bool + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: mode.symbolName) + .font(.system(size: 11, weight: .medium)) + Text(mode.label) + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(backgroundColor) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .help(mode.label) + } + + private var backgroundColor: Color { + if isSelected { + return Color.primary.opacity(0.10) + } + if isHovered { + return Color.primary.opacity(0.05) + } + return Color.clear + } +} + +struct SidebarTerminalPanelView: View { + @ObservedObject var workspace: Workspace + let placement: SidebarTerminalPlacement + let configuration: CmuxSidebarTerminalDefinition? + let isVisibleInUI: Bool + @State private var panel: TerminalPanel? + @State private var config = GhosttyConfig.load() + + var body: some View { + Group { + if let panel, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first { + TerminalPanelView( + panel: panel, + paneId: paneId, + isFocused: isVisibleInUI, + isVisibleInUI: isVisibleInUI, + portalPriority: 4, + isSplit: false, + appearance: PanelAppearance.fromConfig(config), + hasUnreadNotification: false, + onFocus: { + panel.surface.setFocus(true) + panel.surface.requestBackgroundSurfaceStartIfNeeded() + }, + onTriggerFlash: {} + ) + } else { + Text(String(localized: "sidebar.terminal.noWorkspace", defaultValue: "No workspace selected")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + ensurePanel() + } + .onChange(of: workspace.id) { _ in + panel = nil + ensurePanel() + } + .onChange(of: configuration) { _ in + ensurePanel() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in + GhosttyConfig.invalidateLoadCache() + config = GhosttyConfig.load() + } + .onDisappear { + panel?.hostedView.setVisibleInUI(false) + panel?.hostedView.setActive(false) + } + } + + private func ensurePanel() { + let nextPanel = workspace.sidebarTerminalPanel( + for: placement, + configuration: configuration, + preferredWorkingDirectory: preferredWorkingDirectory + ) + guard panel?.id != nextPanel.id else { return } + panel = nextPanel + } + + private var preferredWorkingDirectory: String? { + if let focusedPanelId = workspace.focusedPanelId, + let directory = workspace.panelDirectories[focusedPanelId]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !directory.isEmpty { + return directory + } + let currentDirectory = workspace.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + return currentDirectory.isEmpty ? nil : currentDirectory + } +} + struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel @ObservedObject var fileExplorerState: FileExplorerState let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore + @EnvironmentObject var cmuxConfigStore: CmuxConfigStore @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -10386,6 +10545,8 @@ struct VerticalTabsSidebar: View { @State private var terminalScrollBarVisibilityGeneration: UInt64 = 0 @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + @AppStorage("leftSidebar.mode") + private var leftSidebarMode = LeftSidebarMode.workspaces.rawValue /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 @@ -10408,6 +10569,7 @@ struct VerticalTabsSidebar: View { var body: some View { let _ = terminalScrollBarVisibilityGeneration let tabs = tabManager.tabs + let currentMode = LeftSidebarMode(rawValue: leftSidebarMode) ?? .workspaces let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut @@ -10429,111 +10591,150 @@ struct VerticalTabsSidebar: View { VStack(spacing: 0) { GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - // Space for traffic lights / fullscreen controls - Spacer() - .frame(height: trafficLightPadding) - - // Workspaces are bounded, so prefer a non-lazy stack here. - // LazyVStack + drag-state invalidations can recurse through layout. - VStack(spacing: tabRowSpacing) { - ForEach(tabs, id: \.id) { tab in - let index = tabIndexById[tab.id] ?? 0 - let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) - let contextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedContextTargetIds - : [tab.id] - let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedRemoteContextMenuWorkspaceIds - : (tab.isRemoteWorkspace ? [tab.id] : []) - let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsConnecting - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) - let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsDisconnected - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) - let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && - contextMenuWorkspaceIds.allSatisfy { workspaceId in - workspaceTerminalScrollBarHiddenById[workspaceId] == true - } - let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) - let liveLatestNotificationText: String? = { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil + Group { + if currentMode == .workspaces { + ScrollView { + VStack(spacing: 0) { + // Space for traffic lights / fullscreen controls + Spacer() + .frame(height: trafficLightPadding) + + LeftSidebarModeBar(selectedMode: currentMode) { mode in + leftSidebarMode = mode.rawValue + } + .padding(.horizontal, 6) + .padding(.bottom, 4) + + // Workspaces are bounded, so prefer a non-lazy stack here. + // LazyVStack + drag-state invalidations can recurse through layout. + VStack(spacing: tabRowSpacing) { + ForEach(tabs, id: \.id) { tab in + let index = tabIndexById[tab.id] ?? 0 + let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) + let contextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? selectedContextTargetIds + : [tab.id] + let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? selectedRemoteContextMenuWorkspaceIds + : (tab.isRemoteWorkspace ? [tab.id] : []) + let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets + ? allSelectedRemoteContextMenuTargetsConnecting + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) + let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets + ? allSelectedRemoteContextMenuTargetsDisconnected + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && + contextMenuWorkspaceIds.allSatisfy { workspaceId in + workspaceTerminalScrollBarHiddenById[workspaceId] == true + } + let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) + let liveLatestNotificationText: String? = { + guard showsSidebarNotificationMessage, + let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed + let livePresentation = SidebarTabItemPresentationSnapshot( + tabId: tab.id, + unreadCount: liveUnreadCount, + latestNotificationText: liveLatestNotificationText, + showsModifierShortcutHints: liveShowsModifierShortcutHints + ) + let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id + ? frozenTabItemPresentation + : nil + TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, + tab: tab, + index: index, + isActive: tabManager.selectedTabId == tab.id, + workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( + at: index, + workspaceCount: workspaceCount + ), + workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, + canCloseWorkspace: canCloseWorkspace, + accessibilityWorkspaceCount: workspaceCount, + unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, + latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, + rowSpacing: tabRowSpacing, + setSelectionToTabs: { selection = .tabs }, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, + dragAutoScrollController: dragAutoScrollController, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator, + contextMenuWorkspaceIds: contextMenuWorkspaceIds, + remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, + allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, + allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, + settings: tabItemSettings, + livePresentation: livePresentation, + frozenPresentation: $frozenTabItemPresentation + ) + .equatable() } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }() - let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed - let livePresentation = SidebarTabItemPresentationSnapshot( - tabId: tab.id, - unreadCount: liveUnreadCount, - latestNotificationText: liveLatestNotificationText, - showsModifierShortcutHints: liveShowsModifierShortcutHints - ) - let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id - ? frozenTabItemPresentation - : nil - TabItemView( - tabManager: tabManager, - notificationStore: notificationStore, - tab: tab, - index: index, - isActive: tabManager.selectedTabId == tab.id, - workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( - at: index, - workspaceCount: workspaceCount - ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, - canCloseWorkspace: canCloseWorkspace, - accessibilityWorkspaceCount: workspaceCount, - unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, - latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + + SidebarEmptyArea( rowSpacing: tabRowSpacing, - setSelectionToTabs: { selection = .tabs }, + selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator, - contextMenuWorkspaceIds: contextMenuWorkspaceIds, - remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, - allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, - allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, - settings: tabItemSettings, - livePresentation: livePresentation, - frozenPresentation: $frozenTabItemPresentation + dropIndicator: $dropIndicator ) - .equatable() + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .frame(minHeight: proxy.size.height, alignment: .top) } - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - - SidebarEmptyArea( - rowSpacing: tabRowSpacing, - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator + .background( + SidebarScrollViewResolver { scrollView in + dragAutoScrollController.attach(scrollView: scrollView) + } + .frame(width: 0, height: 0) ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 0) { + Spacer() + .frame(height: trafficLightPadding) + + LeftSidebarModeBar(selectedMode: currentMode) { mode in + leftSidebarMode = mode.rawValue + } + .padding(.horizontal, 6) + .padding(.bottom, 4) + + Divider() + + if let workspace = tabManager.selectedWorkspace { + SidebarTerminalPanelView( + workspace: workspace, + placement: .left, + configuration: cmuxConfigStore.sidebarConfiguration.terminal(for: .left), + isVisibleInUI: true + ) + .id(workspace.id) + } else { + Text(String(localized: "sidebar.terminal.noWorkspace", defaultValue: "No workspace selected")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .top) } - .frame(minHeight: proxy.size.height, alignment: .top) } - .background( - SidebarScrollViewResolver { scrollView in - dragAutoScrollController.attach(scrollView: scrollView) - } - .frame(width: 0, height: 0) - ) .overlay(alignment: .top) { SidebarTopScrim(height: trafficLightPadding + 20) .allowsHitTesting(false) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 20d8a043387a..2ddcabbd4055 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10783,19 +10783,25 @@ final class GhosttySurfaceScrollView: NSView { return } - guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), - let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in - tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) - }) else { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } - guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, - tab.bonsplitController.focusedPaneId == paneId else { - scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") - return + if !tab.isSidebarTerminalPanel(surfaceId) { + guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), + let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in + tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) + }) else { + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") + return + } + + guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, + tab.bonsplitController.focusedPaneId == paneId else { + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") + return + } } // Search focus restoration — only after confirming this is the active tab/pane. @@ -10849,8 +10855,15 @@ final class GhosttySurfaceScrollView: NSView { guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return false + } + + if tab.isSidebarTerminalPanel(surfaceId) { + return true + } + + guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 568f2e6840e5..8a015f1a58c0 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -5,11 +5,13 @@ import SwiftUI enum RightSidebarMode: String, CaseIterable { case files case sessions + case terminal var label: String { switch self { case .files: return String(localized: "rightSidebar.mode.files", defaultValue: "Files") case .sessions: return String(localized: "rightSidebar.mode.sessions", defaultValue: "Sessions") + case .terminal: return String(localized: "rightSidebar.mode.terminal", defaultValue: "Terminal") } } @@ -17,6 +19,7 @@ enum RightSidebarMode: String, CaseIterable { switch self { case .files: return "folder" case .sessions: return "bubble.left.and.text.bubble.right" + case .terminal: return "terminal.fill" } } } @@ -27,6 +30,8 @@ struct RightSidebarPanelView: View { @ObservedObject var fileExplorerState: FileExplorerState @ObservedObject var sessionIndexStore: SessionIndexStore let onResumeSession: ((SessionEntry) -> Void)? + @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var cmuxConfigStore: CmuxConfigStore var body: some View { VStack(spacing: 0) { @@ -73,6 +78,21 @@ struct RightSidebarPanelView: View { .onAppear { sessionIndexStore.setCurrentDirectoryIfChanged(sessionIndexDirectory) } + case .terminal: + if let workspace = tabManager.selectedWorkspace { + SidebarTerminalPanelView( + workspace: workspace, + placement: .right, + configuration: cmuxConfigStore.sidebarConfiguration.terminal(for: .right), + isVisibleInUI: fileExplorerState.isVisible && fileExplorerState.mode == .terminal + ) + .id(workspace.id) + } else { + Text(String(localized: "sidebar.terminal.noWorkspace", defaultValue: "No workspace selected")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c1e86eff7a6e..1ba3719efe14 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4675,7 +4675,7 @@ class TerminalController { skippedPinned += 1 continue } - if workspace.panels.count <= 1 { + if workspace.bonsplitPanelCount <= 1 { break } if workspace.closePanel(panelId, force: true) { @@ -5124,7 +5124,7 @@ class TerminalController { return } - if ws.panels.count <= 1 { + if ws.bonsplitPanelCount <= 1 { result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) return } @@ -10428,7 +10428,7 @@ class TerminalController { return } - if ws.panels.count <= 1 { + if ws.bonsplitPanelCount <= 1 { result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) return } @@ -13452,7 +13452,7 @@ class TerminalController { // Defensive: include any orphaned panels in a stable order at the end. let orphans = tab.panels.values - .filter { !seen.contains($0.id) } + .filter { !seen.contains($0.id) && !tab.isSidebarTerminalPanel($0.id) } .sorted { $0.id.uuidString < $1.id.uuidString } result.append(contentsOf: orphans) @@ -15837,7 +15837,7 @@ class TerminalController { } // Don't close if it's the only surface - if tab.panels.count <= 1 { + if tab.bonsplitPanelCount <= 1 { result = "ERROR: Cannot close the last surface" return } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 55c09bf619cd..9b378954f6fa 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -254,10 +254,10 @@ extension Workspace { let orderedPanelIds = sidebarOrderedPanelIds() var seen: Set = [] var allPanelIds: [UUID] = [] - for panelId in orderedPanelIds where seen.insert(panelId).inserted { + for panelId in orderedPanelIds where !isSidebarTerminalPanel(panelId) && seen.insert(panelId).inserted { allPanelIds.append(panelId) } - for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted { + for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where !isSidebarTerminalPanel(panelId) && seen.insert(panelId).inserted { allPanelIds.append(panelId) } @@ -6988,6 +6988,15 @@ final class Workspace: Identifiable, ObservableObject { /// Mapping from bonsplit TabID (surface ID) to panel UUID private var surfaceIdToPanelId: [TabID: UUID] = [:] + /// Terminal panels rendered in sidebar tabs instead of Bonsplit panes. + private var sidebarTerminalPanelIds: [SidebarTerminalPlacement: UUID] = [:] + private var sidebarTerminalCreationSignatures: [SidebarTerminalPlacement: SidebarTerminalCreationSignature] = [:] + + private struct SidebarTerminalCreationSignature: Equatable { + let startupInput: String? + let workingDirectory: String? + } + /// Tab IDs that are allowed to close even if they would normally require confirmation. /// This is used by app-level confirmation prompts (e.g., Cmd+W "Close Tab?") so the /// Bonsplit delegate doesn't block the close after the user already confirmed. @@ -7252,6 +7261,126 @@ final class Workspace: Identifiable, ObservableObject { panels[panelId] as? TerminalPanel } + func isSidebarTerminalPanel(_ panelId: UUID) -> Bool { + sidebarTerminalPanelIds.values.contains(panelId) + } + + func isBonsplitPanel(_ panelId: UUID) -> Bool { + panels[panelId] != nil && !isSidebarTerminalPanel(panelId) + } + + var bonsplitPanelCount: Int { + panels.keys.reduce(0) { count, panelId in + count + (isSidebarTerminalPanel(panelId) ? 0 : 1) + } + } + + var hasBonsplitPanels: Bool { + panels.keys.contains { !isSidebarTerminalPanel($0) } + } + + func sidebarTerminalPanel( + for placement: SidebarTerminalPlacement, + configuration: CmuxSidebarTerminalDefinition?, + preferredWorkingDirectory: String? + ) -> TerminalPanel { + let baseWorkingDirectory = preferredWorkingDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackWorkingDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + let inheritedWorkingDirectory = (baseWorkingDirectory?.isEmpty == false) + ? baseWorkingDirectory! + : (fallbackWorkingDirectory.isEmpty ? nil : fallbackWorkingDirectory) + let workingDirectory = configuration?.cwd + .map { CmuxConfigStore.resolveCwd($0, relativeTo: inheritedWorkingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path) } + ?? inheritedWorkingDirectory + let startupInput = configuration?.command.map(Self.sidebarTerminalStartupInput) + let signature = SidebarTerminalCreationSignature( + startupInput: startupInput, + workingDirectory: workingDirectory + ) + + if let panelId = sidebarTerminalPanelIds[placement], + let panel = panels[panelId] as? TerminalPanel { + if sidebarTerminalCreationSignatures[placement] == signature { + applySidebarTerminalConfiguration(configuration, to: panel) + return panel + } + _ = closeSidebarTerminalPanel(panelId) + } + + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) + let terminalPanel = TerminalPanel( + workspaceId: id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: inheritedConfig, + workingDirectory: workingDirectory, + portOrdinal: portOrdinal, + initialInput: startupInput, + additionalEnvironment: [ + "CMUX_SIDEBAR_TERMINAL": placement.rawValue + ] + ) + configureTerminalPanel(terminalPanel) + applySidebarTerminalConfiguration(configuration, to: terminalPanel) + panels[terminalPanel.id] = terminalPanel + panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: inheritedConfig) + sidebarTerminalPanelIds[placement] = terminalPanel.id + sidebarTerminalCreationSignatures[placement] = signature + return terminalPanel + } + + private static func sidebarTerminalStartupInput(_ command: String) -> String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return command } + if trimmed.hasSuffix("\n") || trimmed.hasSuffix("\r") { + return trimmed + } + return trimmed + "\n" + } + + private func applySidebarTerminalConfiguration( + _ configuration: CmuxSidebarTerminalDefinition?, + to panel: TerminalPanel + ) { + guard let title = configuration?.title? + .trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { return } + panel.updateTitle(title) + panelTitles[panel.id] = panel.displayTitle + } + + private func closeSidebarTerminalPanel(_ panelId: UUID) -> Bool { + guard let placement = sidebarTerminalPanelIds.first(where: { $0.value == panelId })?.key, + let panel = panels[panelId] else { + return false + } + panel.close() + panels.removeValue(forKey: panelId) + panelDirectories.removeValue(forKey: panelId) + panelGitBranches.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) + panelTitles.removeValue(forKey: panelId) + panelCustomTitles.removeValue(forKey: panelId) + pinnedPanelIds.remove(panelId) + manualUnreadPanelIds.remove(panelId) + manualUnreadMarkedAt.removeValue(forKey: panelId) + panelSubscriptions.removeValue(forKey: panelId) + panelShellActivityStates.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + surfaceListeningPorts.removeValue(forKey: panelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + sidebarTerminalPanelIds.removeValue(forKey: placement) + sidebarTerminalCreationSignatures.removeValue(forKey: placement) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) + return true + } + func browserPanel(for panelId: UUID) -> BrowserPanel? { panels[panelId] as? BrowserPanel } @@ -7785,7 +7914,7 @@ final class Workspace: Identifiable, ObservableObject { } // If this is the only panel and no custom title, update workspace title - if panels.count == 1, customTitle == nil { + if isBonsplitPanel(panelId), bonsplitPanelCount == 1, customTitle == nil { if self.title != trimmed { self.title = trimmed didMutate = true @@ -8242,7 +8371,7 @@ final class Workspace: Identifiable, ObservableObject { } private func clearRemoteConfigurationIfWorkspaceBecameLocal() { - guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } + guard !isDetachingCloseTransaction, !hasBonsplitPanels, remoteConfiguration != nil else { return } disconnectRemoteConnection(clearConfiguration: true) } @@ -8252,7 +8381,7 @@ final class Workspace: Identifiable, ObservableObject { } guard activeRemoteTerminalSurfaceIds.isEmpty else { return } let terminalIds = panels.compactMap { panelId, panel in - panel is TerminalPanel ? panelId : nil + panel is TerminalPanel && !isSidebarTerminalPanel(panelId) ? panelId : nil } guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } trackRemoteTerminalSurface(initialPanelId) @@ -9338,6 +9467,8 @@ final class Workspace: Identifiable, ObservableObject { panels.removeAll(keepingCapacity: false) surfaceIdToPanelId.removeAll(keepingCapacity: false) + sidebarTerminalPanelIds.removeAll(keepingCapacity: false) + sidebarTerminalCreationSignatures.removeAll(keepingCapacity: false) panelSubscriptions.removeAll(keepingCapacity: false) pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) pruneSurfaceMetadata(validSurfaceIds: []) @@ -9350,6 +9481,9 @@ final class Workspace: Identifiable, ObservableObject { /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { + if isSidebarTerminalPanel(panelId) { + return closeSidebarTerminalPanel(panelId) + } if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) @@ -10276,7 +10410,7 @@ final class Workspace: Identifiable, ObservableObject { if shouldFocus { focusPanel(panelId) } - let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1 + let isSplit = bonsplitController.allPaneIds.count > 1 || bonsplitPanelCount > 1 if requiresSplit && !isSplit { return } @@ -10385,7 +10519,7 @@ final class Workspace: Identifiable, ObservableObject { } } - if targetPanelId == nil, let fallbackPanelId = panels.keys.first { + if targetPanelId == nil, let fallbackPanelId = panels.keys.first(where: isBonsplitPanel) { targetPanelId = fallbackPanelId if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId), let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in @@ -11286,7 +11420,7 @@ extension Workspace: BonsplitDelegate { @MainActor private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool { let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager - guard panels.count <= 1, + guard bonsplitPanelCount <= 1, panelIdFromSurfaceId(tabId) != nil, let manager, manager.tabs.contains(where: { $0.id == id }) else { @@ -11924,7 +12058,7 @@ extension Workspace: BonsplitDelegate { // Keep the workspace invariant for normal close paths. // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can // prune the source workspace/window after the tab is attached elsewhere. - if panels.isEmpty { + if !hasBonsplitPanels { if isDetaching { scheduleTerminalGeometryReconcile() return diff --git a/cmuxTests/CmuxConfigTests.swift b/cmuxTests/CmuxConfigTests.swift index 834030d1034e..6684399e3152 100644 --- a/cmuxTests/CmuxConfigTests.swift +++ b/cmuxTests/CmuxConfigTests.swift @@ -77,6 +77,50 @@ final class CmuxConfigDecodingTests: XCTestCase { XCTAssertTrue(config.commands.isEmpty) } + func testDecodeSidebarTerminalWithoutCommands() throws { + let json = """ + { + "sidebar": { + "right": { + "terminal": { + "title": "PR", + "command": "gh pr status", + "cwd": "frontend" + } + } + } + } + """ + let config = try decode(json) + XCTAssertTrue(config.commands.isEmpty) + let terminal = config.sidebar?.runtimeConfiguration.terminal(for: .right) + XCTAssertEqual(terminal?.title, "PR") + XCTAssertEqual(terminal?.command, "gh pr status") + XCTAssertEqual(terminal?.cwd, "frontend") + } + + func testDecodeSidebarTerminalTrimsBlankFields() throws { + let json = """ + { + "commands": [], + "sidebar": { + "left": { + "terminal": { + "title": " ", + "command": " npm run prs ", + "cwd": "" + } + } + } + } + """ + let config = try decode(json) + let terminal = config.sidebar?.runtimeConfiguration.terminal(for: .left) + XCTAssertNil(terminal?.title) + XCTAssertEqual(terminal?.command, "npm run prs") + XCTAssertNil(terminal?.cwd) + } + // MARK: Workspace commands func testDecodeWorkspaceCommand() throws { From 9588df66405b880ff8251d5fa4d63b6cdabf5149 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 21 Apr 2026 16:14:37 -0700 Subject: [PATCH 2/2] Tag sidebar resize UI tests --- cmuxUITests/SidebarResizeUITests.swift | 34 +++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index d8c7d7abd5ee..fa96615defd0 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -1,14 +1,15 @@ import XCTest final class SidebarResizeUITests: XCTestCase { + private let launchTag = "ui-tests-sidebar-resize" + override func setUp() { super.setUp() continueAfterFailure = false } func testSidebarResizerTracksCursor() { - let app = XCUIApplication() - app.launch() + let app = launchApp() let elements = app.descendants(matching: .any) let resizer = elements["SidebarResizer"] @@ -38,8 +39,7 @@ final class SidebarResizeUITests: XCTestCase { } func testSidebarResizerAllowsSmallerMinimumWidth() { - let app = XCUIApplication() - app.launch() + let app = launchApp() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -62,8 +62,7 @@ final class SidebarResizeUITests: XCTestCase { } func testSidebarResizerHasMaximumWidthCap() { - let app = XCUIApplication() - app.launch() + let app = launchApp() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -89,6 +88,29 @@ final class SidebarResizeUITests: XCTestCase { ) } + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["CMUX_TAG"] = launchTag + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launch() + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: 12.0), + "Expected app to launch for sidebar resize test. state=\(app.state.rawValue)" + ) + return app + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in