Merge c4371436a9 into a4cb73db84
commit
d6d3bb8213
|
|
@ -112,6 +112,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,
|
||||
|
|
|
|||
|
|
@ -823,6 +823,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -920,6 +928,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) {
|
||||
|
|
@ -1327,6 +1345,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
|
||||
|
|
@ -1369,6 +1425,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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1113,9 +1113,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,
|
||||
|
|
|
|||
|
|
@ -279,6 +279,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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -2912,6 +2912,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
|
||||
|
|
|
|||
Loading…
Reference in New Issue