macos: add tab sidebar with live previews

Add a new `macos-tab-sidebar` configuration option that replaces the
native macOS tab bar with a vertical sidebar showing live thumbnail
previews of all terminal tabs.

Features:
- Live preview thumbnails updated at ~5fps
- Sidebar takes 50% of window width
- Auto-adjusts number of columns to fit all tabs without scrolling
- Tab numbers 1-9 shown as badges for keyboard navigation
- Cmd+1-9 shortcuts to switch between tabs
- Click to select, hover X to close
- New tabs automatically focused
- Respects background opacity settings (sidebar is 50% less transparent)
- Context menu support for tab actions

The sidebar mode creates splits internally instead of native tabs,
allowing all terminals to be rendered and captured for previews.
pull/9931/head
Jairo Caro-Accino 2025-12-16 17:15:45 +01:00 committed by Jairo
parent 0a0068002a
commit c4371436a9
10 changed files with 840 additions and 36 deletions

View File

@ -111,6 +111,8 @@
Features/Splits/SplitView.Divider.swift,
Features/Splits/SplitView.swift,
Features/Splits/TerminalSplitTreeView.swift,
Features/TabSidebar/TabPreviewManager.swift,
Features/TabSidebar/TabSidebarView.swift,
Features/Terminal/BaseTerminalController.swift,
Features/Terminal/ErrorView.swift,
Features/Terminal/TerminalController.swift,

View File

@ -799,6 +799,19 @@ class AppDelegate: NSObject,
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
// In sidebar mode, create a split instead of a native tab
if ghostty.config.macosTabSidebar {
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyNewSplit,
object: surfaceView,
userInfo: [
"direction": GHOSTTY_SPLIT_DIRECTION_RIGHT,
Ghostty.Notification.NewSurfaceConfigKey: config as Any
].compactMapValues { $0 }
)
return
}
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
}

View File

@ -0,0 +1,138 @@
import AppKit
import Combine
import SwiftUI
/// Manages thumbnail preview generation for terminal surfaces displayed in the tab sidebar.
/// Updates previews at a throttled rate (~5fps) to balance responsiveness with performance.
class TabPreviewManager: ObservableObject {
/// Target FPS for preview updates (throttled to reduce GPU/CPU usage)
static let targetFPS: Double = 5.0
private let updateInterval: TimeInterval = 1.0 / targetFPS
/// Preview images keyed by surface ID
@Published private(set) var previews: [UUID: NSImage] = [:]
/// The surfaces being tracked for preview generation
private var surfaces: [Ghostty.SurfaceView] = []
/// Timer for throttled updates
private var updateTimer: Timer?
/// Preview thumbnail size
private(set) var thumbnailSize: CGSize
/// Background queue for preview generation
private let previewQueue = DispatchQueue(label: "com.ghostty.tabPreviewManager", qos: .userInitiated)
/// Initializes the preview manager with a specified thumbnail width.
/// - Parameter thumbnailWidth: The width of generated thumbnails in points. Height is calculated to maintain aspect ratio.
init(thumbnailWidth: CGFloat = 180) {
// Use 16:10 aspect ratio typical for terminal windows
self.thumbnailSize = CGSize(width: thumbnailWidth, height: thumbnailWidth * 0.625)
}
/// Starts tracking the given surfaces for preview generation.
/// - Parameter surfaces: The surfaces to generate previews for.
func startTracking(surfaces: [Ghostty.SurfaceView]) {
self.surfaces = surfaces
startUpdateTimer()
// Generate initial previews immediately
updatePreviews()
}
/// Stops tracking all surfaces and invalidates the update timer.
func stopTracking() {
updateTimer?.invalidate()
updateTimer = nil
surfaces = []
}
/// Updates the list of tracked surfaces without restarting the timer.
/// - Parameter surfaces: The new list of surfaces to track.
func updateSurfaces(_ surfaces: [Ghostty.SurfaceView]) {
self.surfaces = surfaces
// Remove previews for surfaces that no longer exist
let surfaceIds = Set(surfaces.map { $0.id })
previews = previews.filter { surfaceIds.contains($0.key) }
}
// MARK: - Private Methods
private func startUpdateTimer() {
updateTimer?.invalidate()
updateTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { [weak self] _ in
self?.updatePreviews()
}
// Add to common run loop mode so timer fires during UI interactions
if let timer = updateTimer {
RunLoop.current.add(timer, forMode: .common)
}
}
private func updatePreviews() {
// Capture surfaces on main thread since they're NSViews
let surfacesToCapture = surfaces
previewQueue.async { [weak self] in
guard let self = self else { return }
var newPreviews: [UUID: NSImage] = [:]
for surface in surfacesToCapture {
// Must capture screenshot on main thread
var screenshot: NSImage?
DispatchQueue.main.sync {
screenshot = surface.screenshot()
}
// Use full resolution screenshot for better quality
if let fullImage = screenshot {
newPreviews[surface.id] = fullImage
}
}
DispatchQueue.main.async {
// Only update changed previews to minimize UI updates
for (id, image) in newPreviews {
self.previews[id] = image
}
}
}
}
/// Creates a scaled-down thumbnail from the full-size image.
/// - Parameter image: The source image to scale down.
/// - Returns: A thumbnail image, or nil if scaling fails.
private func createThumbnail(from image: NSImage) -> NSImage? {
let sourceSize = image.size
guard sourceSize.width > 0 && sourceSize.height > 0 else { return nil }
// Calculate scale to fit within thumbnail size while maintaining aspect ratio
let scaleX = thumbnailSize.width / sourceSize.width
let scaleY = thumbnailSize.height / sourceSize.height
let scale = min(scaleX, scaleY)
let targetSize = CGSize(
width: sourceSize.width * scale,
height: sourceSize.height * scale
)
let thumbnail = NSImage(size: targetSize)
thumbnail.lockFocus()
NSGraphicsContext.current?.imageInterpolation = .high
image.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: sourceSize),
operation: .copy,
fraction: 1.0
)
thumbnail.unlockFocus()
return thumbnail
}
deinit {
stopTracking()
}
}

View File

@ -0,0 +1,265 @@
import SwiftUI
/// Represents a tab item in the sidebar with its associated surface information.
struct SidebarTabItem: Identifiable {
let id: UUID
let surfaceID: UUID
let title: String
let tabIndex: Int
init(surface: Ghostty.SurfaceView, index: Int) {
self.id = surface.id
self.surfaceID = surface.id
self.title = surface.title
self.tabIndex = index
}
}
/// The main tab sidebar view that displays tabs in a vertical column with live previews.
struct TabSidebarView: View {
@EnvironmentObject var ghostty: Ghostty.App
@ObservedObject var previewManager: TabPreviewManager
/// All tab items to display in the sidebar
let tabItems: [SidebarTabItem]
/// Currently selected surface ID
@Binding var selectedSurfaceID: UUID?
/// Sidebar width
let sidebarWidth: CGFloat
/// Sidebar height (for calculating optimal column count)
let sidebarHeight: CGFloat
/// Callbacks for tab actions
let onNewTab: () -> Void
let onCloseTab: (UUID) -> Void
let onSelectTab: (UUID) -> Void
/// Calculates the optimal number of columns to fit all tabs without scrolling
private var columnCount: Int {
guard tabItems.count > 0 else { return 1 }
// Get the aspect ratio from the first preview
guard let firstItem = tabItems.first,
let preview = previewManager.previews[firstItem.surfaceID],
preview.size.width > 0, preview.size.height > 0 else {
return 1
}
let previewAspectRatio = preview.size.width / preview.size.height
let padding: CGFloat = 16 // horizontal padding
let spacing: CGFloat = 8 // spacing between items
let verticalPadding: CGFloat = 24 // top and bottom padding
let titleHeight: CGFloat = 20 // approximate height for title + padding
let buttonHeight: CGFloat = 50 // height for the New Tab button area
let availableHeight = sidebarHeight - verticalPadding - buttonHeight
// Find the minimum number of columns that fits all tabs without scrolling
for columns in 1...12 {
let totalHSpacing = spacing * CGFloat(columns - 1)
let columnWidth = (sidebarWidth - padding - totalHSpacing) / CGFloat(columns)
let previewHeight = columnWidth / previewAspectRatio
let itemHeight = previewHeight + titleHeight + 12 // 12 for item padding
let rows = ceil(Double(tabItems.count) / Double(columns))
let totalVSpacing = spacing * CGFloat(rows - 1)
let totalContentHeight = itemHeight * CGFloat(rows) + totalVSpacing
// If all items fit in the available height, use this column count
if totalContentHeight <= availableHeight {
return columns
}
}
return 12 // Maximum columns
}
private var columns: [GridItem] {
return Array(repeating: GridItem(.flexible(), spacing: 8), count: columnCount)
}
var body: some View {
VStack(spacing: 0) {
// Tab list with scroll
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(tabItems) { item in
TabSidebarItemView(
item: item,
preview: previewManager.previews[item.surfaceID],
isSelected: item.surfaceID == selectedSurfaceID,
onSelect: { onSelectTab(item.surfaceID) },
onClose: { onCloseTab(item.surfaceID) },
onNewTab: onNewTab
)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
Divider()
// New tab button at the bottom
Button(action: onNewTab) {
HStack {
Image(systemName: "plus")
Text("New Tab")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.buttonStyle(.borderless)
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
.frame(width: sidebarWidth)
.background(
ghostty.config.backgroundColor
// Make sidebar 50% less transparent than terminal
// If terminal opacity is 0.8, sidebar will be 0.9
.opacity(ghostty.config.backgroundOpacity + (1 - ghostty.config.backgroundOpacity) * 0.5)
)
}
}
/// Individual tab item view with preview thumbnail, title, and hover actions.
struct TabSidebarItemView: View {
let item: SidebarTabItem
let preview: NSImage?
let isSelected: Bool
let onSelect: () -> Void
let onClose: () -> Void
let onNewTab: () -> Void
@State private var isHovering = false
@State private var isPulsing = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
// Preview thumbnail
ZStack(alignment: .topTrailing) {
previewImage
.frame(maxWidth: .infinity)
.clipped()
.cornerRadius(6)
// Tab number badge (top right) - only show for tabs 1-9
if item.tabIndex < 9 {
Text("\(item.tabIndex + 1)")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundColor(.white)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Color.black.opacity(0.6))
)
.padding(4)
.opacity(isHovering ? 0 : 1) // Hide when close button is shown
}
// Close button shown on hover
if isHovering {
Button(action: onClose) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
}
.buttonStyle(.plain)
.padding(4)
}
}
// Tab title
Text(displayTitle)
.font(.caption)
.lineLimit(1)
.truncationMode(.tail)
.foregroundColor(isSelected ? .primary : .secondary)
}
.padding(6)
.background(selectionBackground)
.overlay(selectionBorder)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.onHover { hovering in
isHovering = hovering
}
.contextMenu {
Button("Close Tab", action: onClose)
Divider()
Button("New Tab", action: onNewTab)
}
.onAppear {
if !isSelected {
startPulsingAnimation()
}
}
.onChange(of: isSelected) { selected in
if !selected {
startPulsingAnimation()
} else {
isPulsing = false
}
}
}
private func startPulsingAnimation() {
withAnimation(Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
isPulsing = true
}
}
// MARK: - Private Views
@ViewBuilder
private var previewImage: some View {
if let preview = preview {
Image(nsImage: preview)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.scaleEffect(0.6)
)
}
}
private var displayTitle: String {
item.title.isEmpty ? "Terminal" : item.title
}
private var selectionBackground: some View {
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.accentColor.opacity(0.2) : Color.clear)
}
private var selectionBorder: some View {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
isSelected
? Color.accentColor // Selected: solid accent color
: Color.accentColor.opacity(isPulsing ? 0.6 : 0.2), // Unselected: pulsing blue
lineWidth: isSelected ? 2.5 : (isPulsing ? 1.5 : 1.0)
)
.animation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true), value: isPulsing)
}
}
// MARK: - Preview Provider
#if DEBUG
struct TabSidebarView_Previews: PreviewProvider {
static var previews: some View {
// Preview requires mock data - this is just for development
EmptyView()
}
}
#endif

View File

@ -61,6 +61,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
/// This will be set to the initial frame of the window from the xib on load.
private var initialFrame: NSRect? = nil
/// Event monitor for sidebar tab switching (Cmd+1-9)
private var sidebarTabEventMonitor: Any? = nil
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
@ -143,6 +146,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
// Remove sidebar tab event monitor if it exists
if let monitor = sidebarTabEventMonitor {
NSEvent.removeMonitor(monitor)
}
}
// MARK: Base Controller Overrides
@ -924,6 +932,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// use whatever the latest app-level config is.
let config = ghostty.config
// If sidebar mode is enabled, disable native tabbing since we manage tabs in the sidebar
if config.macosTabSidebar {
window.tabbingMode = .disallowed
// Add event monitor for Cmd+1-9 tab switching in sidebar mode
sidebarTabEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
return self?.handleSidebarTabKeyEvent(event) ?? event
}
}
// Setting all three of these is required for restoration to work.
window.isRestorable = restorable
if (restorable) {
@ -1331,6 +1349,44 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
let tabIndex: Int32 = tabEnum.rawValue
// Handle sidebar mode - switch between surfaces instead of native tabs
if ghostty.config.macosTabSidebar {
let surfaces = Array(surfaceTree)
guard surfaces.count > 1 else { return }
// Find current surface index
guard let currentIndex = surfaces.firstIndex(where: { $0 == focusedSurface }) else { return }
let finalIndex: Int
if tabIndex <= 0 {
if tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue {
finalIndex = currentIndex == 0 ? surfaces.count - 1 : currentIndex - 1
} else if tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue {
finalIndex = currentIndex == surfaces.count - 1 ? 0 : currentIndex + 1
} else if tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue {
finalIndex = surfaces.count - 1
} else {
return
}
} else {
// The configured value is 1-indexed
finalIndex = min(Int(tabIndex - 1), surfaces.count - 1)
}
guard finalIndex >= 0 && finalIndex < surfaces.count else { return }
let targetSurface = surfaces[finalIndex]
// Post notification for TerminalView to handle the tab selection
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttySelectSidebarTab,
object: self,
userInfo: [
Ghostty.Notification.SidebarTabSurfaceIDKey: targetSurface.id
]
)
return
}
// Standard native tab handling
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
let tabbedWindows = tabGroup.windows
@ -1373,6 +1429,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
targetWindow.makeKeyAndOrderFront(nil)
}
/// Handles Cmd+1-9 key events for sidebar tab switching
private func handleSidebarTabKeyEvent(_ event: NSEvent) -> NSEvent? {
// Only handle if this window is key
guard window?.isKeyWindow == true else { return event }
// Must have command modifier and no other modifiers (except shift for Cmd+9)
guard event.modifierFlags.contains(.command) else { return event }
let otherMods: NSEvent.ModifierFlags = [.control, .option]
guard event.modifierFlags.isDisjoint(with: otherMods) else { return event }
// Check for digit keys 1-9
guard let chars = event.charactersIgnoringModifiers,
let char = chars.first,
char >= "1" && char <= "9" else { return event }
let surfaces = Array(surfaceTree)
guard surfaces.count > 1 else { return event }
let tabIndex: Int
if char == "9" {
// Cmd+9 goes to last tab
tabIndex = surfaces.count - 1
} else {
// Cmd+1-8 goes to that tab (1-indexed)
tabIndex = Int(char.asciiValue! - Character("1").asciiValue!)
guard tabIndex < surfaces.count else { return event }
}
let targetSurface = surfaces[tabIndex]
// Post notification for TerminalView to handle the tab selection
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttySelectSidebarTab,
object: self,
userInfo: [
Ghostty.Notification.SidebarTabSurfaceIDKey: targetSurface.id
]
)
// Consume the event
return nil
}
@objc private func onCloseTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }

View File

@ -45,7 +45,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// An optional delegate to receive information about terminal changes.
weak var delegate: (any TerminalViewDelegate)? = nil
// The most recently focused surface, equal to focusedSurface when
// it is non-nil.
@State private var lastFocusedSurface: Weak<Ghostty.SurfaceView> = .init()
@ -58,6 +58,12 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// Preview manager for tab sidebar mode
@StateObject private var previewManager = TabPreviewManager()
// Currently selected surface ID for tab sidebar
@State private var selectedSurfaceID: UUID?
// The pwd of the focused surface as a URL
private var pwdURL: URL? {
guard let surfacePwd, surfacePwd != "" else { return nil }
@ -71,23 +77,92 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
case .error:
ErrorView()
case .ready:
ZStack {
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
if ghostty.config.macosTabSidebar {
sidebarLayout
} else {
mainContent
}
}
}
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
// MARK: - Sidebar Layout
@ViewBuilder
private var sidebarLayout: some View {
GeometryReader { geometry in
let sidebarWidth = geometry.size.width * 0.5
HStack(spacing: 0) {
TabSidebarView(
previewManager: previewManager,
tabItems: gatherTabItems(),
selectedSurfaceID: $selectedSurfaceID,
sidebarWidth: sidebarWidth,
sidebarHeight: geometry.size.height,
onNewTab: handleNewTab,
onCloseTab: handleCloseTab,
onSelectTab: handleSelectTab
)
.environmentObject(ghostty)
// In sidebar mode, only show the selected surface, not all splits
sidebarMainContent
}
.background(Color.clear)
}
.background(Color.clear)
.onAppear {
startPreviewTracking()
}
.onDisappear {
previewManager.stopTracking()
}
.onChange(of: viewModel.surfaceTree.count) { newCount in
let oldCount = previewManager.previews.count
updatePreviewTracking()
// If a new surface was added, select and focus it
if newCount > oldCount {
let surfaces = Array(viewModel.surfaceTree)
if let newSurface = surfaces.last {
selectedSurfaceID = newSurface.id
lastFocusedSurface = .init(newSurface)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
Ghostty.moveFocus(to: newSurface)
}
}
}
}
.onChange(of: focusedSurface) { newValue in
if let surface = newValue {
selectedSurfaceID = surface.id
}
}
.onReceive(NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttySelectSidebarTab)) { notification in
guard let surfaceID = notification.userInfo?[Ghostty.Notification.SidebarTabSurfaceIDKey] as? UUID else { return }
handleSelectTab(surfaceID)
}
}
// MARK: - Sidebar Main Content (shows only selected tab)
@ViewBuilder
private var sidebarMainContent: some View {
ZStack {
VStack(spacing: 0) {
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
// Only show the selected surface, not all splits
if let selectedID = selectedSurfaceID,
let selectedSurface = findSurface(by: selectedID) {
Ghostty.InspectableSurface(surfaceView: selectedSurface, isSplit: false)
.id(selectedID) // Force view recreation when selected tab changes
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
// We want to keep track of our last focused surface so even if
// we lose focus we keep this set to the last non-nil value.
if newValue != nil {
lastFocusedSurface = .init(newValue)
self.delegate?.focusedSurfaceDidChange(to: newValue)
@ -100,28 +175,205 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.frame(idealWidth: lastFocusedSurface.value?.initialSize?.width,
idealHeight: lastFocusedSurface.value?.initialSize?.height)
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
if let surfaceView = lastFocusedSurface.value {
TerminalCommandPaletteView(
surfaceView: surfaceView,
isPresented: $viewModel.commandPaletteIsShowing,
ghosttyConfig: ghostty.config,
updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in
self.delegate?.performAction(action, on: surfaceView)
}
}
// Show update information above all else.
if viewModel.updateOverlayIsVisible {
UpdateOverlay()
} else {
// Fallback: show the first surface if no selection
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
}
}
.frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude)
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
if let surfaceView = lastFocusedSurface.value {
TerminalCommandPaletteView(
surfaceView: surfaceView,
isPresented: $viewModel.commandPaletteIsShowing,
ghosttyConfig: ghostty.config,
updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in
self.delegate?.performAction(action, on: surfaceView)
}
}
if viewModel.updateOverlayIsVisible {
UpdateOverlay()
}
}
.frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude)
.background(Color.clear)
}
// MARK: - Main Content (standard mode)
@ViewBuilder
private var mainContent: some View {
ZStack {
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
TerminalSplitTreeView(
tree: viewModel.surfaceTree,
onResize: { delegate?.splitDidResize(node: $0, to: $1) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
// We want to keep track of our last focused surface so even if
// we lose focus we keep this set to the last non-nil value.
if newValue != nil {
lastFocusedSurface = .init(newValue)
self.delegate?.focusedSurfaceDidChange(to: newValue)
}
}
.onChange(of: pwdURL) { newValue in
self.delegate?.pwdDidChange(to: newValue)
}
.onChange(of: cellSize) { newValue in
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
.frame(idealWidth: lastFocusedSurface.value?.initialSize?.width,
idealHeight: lastFocusedSurface.value?.initialSize?.height)
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
if let surfaceView = lastFocusedSurface.value {
TerminalCommandPaletteView(
surfaceView: surfaceView,
isPresented: $viewModel.commandPaletteIsShowing,
ghosttyConfig: ghostty.config,
updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in
self.delegate?.performAction(action, on: surfaceView)
}
}
// Show update information above all else.
if viewModel.updateOverlayIsVisible {
UpdateOverlay()
}
}
.frame(maxWidth: .greatestFiniteMagnitude, maxHeight: .greatestFiniteMagnitude)
}
// MARK: - Tab Sidebar Helpers
/// Finds a surface by its ID in the surface tree.
private func findSurface(by id: UUID) -> Ghostty.SurfaceView? {
for surface in viewModel.surfaceTree {
if surface.id == id {
return surface
}
}
return nil
}
/// Gathers all surfaces from the split tree into tab items.
private func gatherTabItems() -> [SidebarTabItem] {
var items: [SidebarTabItem] = []
var index = 0
for surface in viewModel.surfaceTree {
items.append(SidebarTabItem(surface: surface, index: index))
index += 1
}
return items
}
/// Starts preview tracking for all current surfaces.
private func startPreviewTracking() {
let surfaces = Array(viewModel.surfaceTree)
previewManager.startTracking(surfaces: surfaces)
// Set initial selection to focused surface or first surface
if selectedSurfaceID == nil {
selectedSurfaceID = focusedSurface?.id ?? surfaces.first?.id
}
}
/// Updates the preview manager when the surface tree changes.
private func updatePreviewTracking() {
let surfaces = Array(viewModel.surfaceTree)
previewManager.updateSurfaces(surfaces)
// If selected surface was removed, select the first one
if let selectedID = selectedSurfaceID, findSurface(by: selectedID) == nil {
selectedSurfaceID = surfaces.first?.id
}
}
/// Handles new tab creation from the sidebar.
private func handleNewTab() {
// In sidebar mode, create a new split (which adds a surface to the tree)
// The new surface will appear as a new tab in the sidebar
// Try to find a surface to split from: selected, last focused, or first in tree
let targetSurface: Ghostty.SurfaceView?
if let selectedID = selectedSurfaceID, let surface = findSurface(by: selectedID) {
targetSurface = surface
} else if let surface = lastFocusedSurface.value {
targetSurface = surface
} else {
targetSurface = Array(viewModel.surfaceTree).first
}
guard let surface = targetSurface else { return }
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyNewSplit,
object: surface,
userInfo: [
"direction": GHOSTTY_SPLIT_DIRECTION_RIGHT
]
)
}
/// Handles tab closing from the sidebar.
private func handleCloseTab(_ surfaceID: UUID) {
// Find the surface with the given ID and close it
if let surface = findSurface(by: surfaceID) {
// If this is the selected tab, select another one first
var newSurfaceToFocus: Ghostty.SurfaceView? = nil
if surfaceID == selectedSurfaceID {
let surfaces = Array(viewModel.surfaceTree)
if let currentIndex = surfaces.firstIndex(where: { $0.id == surfaceID }) {
// Select the previous tab, or next if this is the first
if currentIndex > 0 {
newSurfaceToFocus = surfaces[currentIndex - 1]
selectedSurfaceID = newSurfaceToFocus?.id
} else if surfaces.count > 1 {
newSurfaceToFocus = surfaces[1]
selectedSurfaceID = newSurfaceToFocus?.id
}
}
}
// Close the surface
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyCloseSurface,
object: surface
)
// Focus the new surface after a short delay to allow the close to process
if let newSurface = newSurfaceToFocus {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.lastFocusedSurface = .init(newSurface)
Ghostty.moveFocus(to: newSurface)
}
}
}
}
/// Handles tab selection from the sidebar.
private func handleSelectTab(_ surfaceID: UUID) {
// Find the surface and move focus to it
if let surface = findSurface(by: surfaceID) {
selectedSurfaceID = surfaceID
lastFocusedSurface = .init(surface)
Ghostty.moveFocus(to: surface)
}
}
}

View File

@ -1085,9 +1085,16 @@ extension Ghostty {
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
// Similar to goto_split (see comment there) about our performability,
// we should make this more accurate later.
guard (surfaceView.window?.tabGroup?.windows.count ?? 0) > 1 else { return false }
// Check if sidebar mode is enabled - if so, check for multiple surfaces instead of tabs
if let controller = surfaceView.window?.windowController as? BaseTerminalController,
controller.ghostty.config.macosTabSidebar {
// In sidebar mode, we need at least 2 surfaces to switch between
guard controller.surfaceTree.count > 1 else { return false }
} else {
// Similar to goto_split (see comment there) about our performability,
// we should make this more accurate later.
guard (surfaceView.window?.tabGroup?.windows.count ?? 0) > 1 else { return false }
}
NotificationCenter.default.post(
name: Notification.ghosttyGotoTab,

View File

@ -271,6 +271,14 @@ extension Ghostty {
return String(cString: ptr)
}
var macosTabSidebar: Bool {
guard let config = self.config else { return false }
var v = false
let key = "macos-tab-sidebar"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v
}
var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon {
let defaultValue = MacOSTitlebarProxyIcon.visible
guard let config = self.config else { return defaultValue }

View File

@ -429,6 +429,10 @@ extension Ghostty.Notification {
static let ghosttyGotoTab = Notification.Name("com.mitchellh.ghostty.gotoTab")
static let GotoTabKey = ghosttyGotoTab.rawValue
/// Select sidebar tab. Has surface UUID in the userinfo.
static let ghosttySelectSidebarTab = Notification.Name("com.mitchellh.ghostty.selectSidebarTab")
static let SidebarTabSurfaceIDKey = "surfaceID"
/// New tab. Has base surface config requested in userinfo.
static let ghosttyNewTab = Notification.Name("com.mitchellh.ghostty.newTab")

View File

@ -2892,6 +2892,23 @@ keybind: Keybinds = .{},
/// Changing this option at runtime only applies to new windows.
@"macos-titlebar-style": MacTitlebarStyle = .transparent,
/// Whether to use a sidebar for tabs instead of the native macOS tab bar.
/// When enabled, tabs are shown in a vertical column on the left side of
/// the window with live thumbnail previews. The sidebar takes 50% of the
/// window width and automatically adjusts the number of columns to fit
/// all tabs without scrolling.
///
/// Valid values are:
///
/// * `false` - Use native macOS tab bar (default)
/// * `true` - Use sidebar with tab previews
///
/// This option is only supported on macOS and has no effect on other
/// platforms.
///
/// Changing this option at runtime only applies to new windows.
@"macos-tab-sidebar": bool = false,
/// Whether the proxy icon in the macOS titlebar is visible. The proxy icon
/// is the icon that represents the folder of the current working directory.
/// You can see this very clearly in the macOS built-in Terminal.app