cmux/Sources/Workspace.swift
Lawrence Chen 50f0dd334d
Fix frozen terminals after split churn (#12)
* Fix blank terminal after split operations and add visual tests

## Blank Terminal Fix
- Add `needsRefreshAfterWindowChange` flag in GhosttyTerminalView
- Force terminal refresh when view is added to window, even if size unchanged
- Add `ghostty_surface_refresh()` call in attachToView for same-view reattachment
- Add debug logging for surface attachment lifecycle (DEBUG builds only)

## Bonsplit Migration
- Add bonsplit as local Swift package (vendor/bonsplit submodule)
- Replace custom SplitTree with BonsplitController
- Add Panel protocol with TerminalPanel and BrowserPanel implementations
- Add SidebarTab as main tab container with BonsplitController
- Remove old Splits/ directory (SplitTree, SplitView, TerminalSplitTreeView)

## Visual Screenshot Tests
- Add test_visual_screenshots.py for automated visual regression testing
- Uses in-app screenshot API (CGWindowListCreateImage) - no screen recording needed
- Generates HTML report with before/after comparisons
- Tests: splits, browser panels, focus switching, close operations, rapid cycles
- Includes annotation fields for easy feedback

## Browser Shortcut (⌘⇧B)
- Add keyboard shortcut to open browser panel in current pane
- Add openBrowser() method to TabManager
- Add shortcut configuration in KeyboardShortcutSettings

## Screenshot Command
- Add 'screenshot' command to TerminalController for in-app window capture
- Returns OK with screenshot ID and path

## Other
- Add tests/visual_output/ and tests/visual_report.html to .gitignore

* Add browser title subscription and set tab height to 30px

- Subscribe to BrowserPanel.$pageTitle changes to update bonsplit tabs
- Update tab titles in real-time as page navigation occurs
- Clean up subscriptions when panels are removed
- Set bonsplit tab bar and tab height to 30px (in submodule)

* Fix socket API regressions in list_surfaces, list_bonsplit_tabs, focus_pane

- list_surfaces: Remove [terminal]/[browser] suffix to keep UUID-only format
  that clients and tests expect for parsing
- list_bonsplit_tabs --pane: Properly look up pane by UUID instead of
  creating a new PaneID (requires bonsplit PaneID.id to be public)
- focus_pane: Accept both UUID strings and integer indices as documented

* Fix browser panel stability and keyboard shortcuts

- Prevent WKWebView focus lifecycle crashes during split/view reshuffles
- Match bracket shortcuts via keyCode (Cmd+Shift+[ / ], Cmd+Ctrl+[ / ])
- Support Ghostty config goto_split:* keybinds when WebView is focused
- Add focus_webview/is_webview_focused socket commands and regression tests
- Rename SidebarTab to Workspace and update docs

* Make ctrl+enter keybind test skippable

Skip when the Ghostty keybind isn't configured or when osascript can't send keystrokes (no Accessibility permission), so VM runs stay green.

* Auto-focus browser omnibar when blank

When a browser surface is focused but no URL is loaded yet, focus the address bar instead of the WKWebView.

* Stabilize socket surface indexing

* Focus browser omnibar escape; add webview keybind UI tests

- Escape in omnibar now returns focus to WKWebView\n- Add UI tests for Cmd+Ctrl+H pane navigation with WebKit focused (including Ghostty config)\n- Avoid flaky element screenshots in UpdatePillUITests on the UTM VM

* Fix browser drag-to-split blanks and socket parsing

* Fix webview-focused shortcuts and stabilize browser splits

- Match ctrl/shift shortcuts by keyCode where needed (Ctrl+H, bracket keys)
- Load Ghostty goto_split triggers reliably and refresh on config load
- Add debug socket helpers: set_shortcut + simulate_shortcut for tests
- Convert browser goto_split/keybind tests to socket-based injection (no osascript)
- Bump bonsplit for drag-to-split fixes

* Fix split layout collapse and harden socket pane APIs

* Stabilize OSC 99 notification test timing

* Fix terminal focus routing after split reparent

* Support simulate_shortcut enter for focus routing test

* Stabilize terminal focus routing test

* Fix frozen new terminal tabs after many splits

* Fix frozen new terminal tabs after splits

* Fix terminal freeze on launch/new tabs

* Update ghostty submodule

* Fix terminal focus/render stalls after split churn

* Fix nested split collapsing existing pane

* Fix nested split collapse + stabilize new-surface focus

* Update bonsplit submodule

* Fix SIGINT test flake

* Remove bonsplit tab-switch crossfade

* Remove PROJECTS.md

* Remove bonsplit tab selection animation

* Ignore generated test reports

* Middle click closes tab

* Revert unintended .gitignore change

* Fix build after main merge

* Revert "Fix build after main merge"

This reverts commit 16bf9816d0856b5385d52f886aa5eb50f3c9d9a4.

* Revert "Merge remote-tracking branch 'origin/main' into fix/blank-terminal-and-visual-tests"

This reverts commit 7c20fb53fd71fea7a19a3673f2dd73e5f0c783c4, reversing
changes made to 0aff107d787bc9d8bbc28220090b4ca7af72e040.

* Remove tab close fade animation

* Use terminal.fill icon

* Make terminal tab icon smaller

* Match browser globe tab icon size

* Bonsplit: tab min width 48 and tighter close button

* Bonsplit: smaller tab title font

* Show unread notification badge in bonsplit tabs and improve UI polish

Sync unread notification state to bonsplit tab badges (blue dot).
Improve EmptyPanelView with Terminal/Browser buttons and shortcut hints.
Add tooltips to close tab button and search overlay buttons.

* Fix reload.sh single-instance safety check on macOS

Replace GNU-only `ps -o etimes=` with portable `ps -o etime=` and
parse the dd-hh:mm:ss format manually for macOS compatibility.

* Centralize keyboard shortcut definitions into Action enum

Replace per-shortcut boilerplate with a single Action enum that holds
the label, defaults key, and default binding for each shortcut. All
call sites now use shortcut(for:). Settings UI is data-driven via
ForEach(Action.allCases). Titlebar tooltips update dynamically when
shortcuts are changed. Remove duplicate .keyboardShortcut() modifiers
from menu items that are already handled by the event monitor.

* Fix WKWebView consuming app menu shortcuts and close panel confirmation

Add CmuxWebView subclass that routes key equivalents through the main
menu before WebKit, so Cmd+N/Cmd+W/tab switching work when a browser
pane is focused. Fix Cmd+W close-panel path: bypass Bonsplit delegate
gating after the user confirms the running-process dialog by tracking
forceCloseTabIds. Add unit tests (CmuxWebViewKeyEquivalentTests) and
UI test scaffolding (MenuKeyEquivalentRoutingUITests) with a new
cmux-unit Xcode scheme.

* Update CLAUDE.md and PROJECTS.md with recent changes

CLAUDE.md: enforce --tag for reload commands, add cleanup safety rules.
PROJECTS.md: log notification badge, reload.sh fix, Cmd+W fix, WebView
key equiv fix, and centralized shortcuts work.

* Keep selection index stable on close

* Add concepts page documenting terminology hierarchy

New docs page explaining Window > Workspace > Pane > Surface > Panel
hierarchy with aligned ASCII diagram. Updated tabs.mdx and splits.mdx
to use consistent terminology (workspace instead of tab, surface
instead of panel) and corrected outdated CLI command references.

* Update bonsplit submodule

* WIP: improve split close stability and UI regressions

* Close terminal panel on child exit; hide terminal dirty dot

* Fix split close/focus regressions and stabilize UI tests

* Add unread Dock/Cmd+Tab badge with settings toggle

* Fix browser-surface shortcuts and Cmd+L browser opening

* Snapshot current workspace state before regression fixes

* Update bonsplit submodule snapshot

* Stabilize split-close regression capture and sidebar resize assertions

* Change default Show Notifications shortcut from Cmd+Shift+I to Cmd+I

* Fix update check readiness race, enable release update logging, and improve checking spinner

* Restore terminal file drop, fix browser omnibar click focus, and add panel workspace ID mutation for surface moves

* Add Cmd+digit workspace hints, titlebar shortcut pills, sidebar drag-reorder, and workspace placement settings

* Add v2 browser automation API, surface move/reorder commands, and short-handle ref system to TerminalController

* Add CLI browser command surface, --id-format flag, and move/reorder commands

* Extend test clients with move/reorder APIs, ref-handle support, and increased timeouts

* Harden test runner scripts with deterministic builds, retry logic, and robust socket readiness

* Stabilize existing test suites with focus-wait helpers, increased timeouts, and API shape updates

* Add terminal file drop e2e regression test

* Add v2 browser API, CLI ref resolution, and surface move/reorder test suites

* Add unit tests for shortcut hints, workspace reorder, drop planner, and update UI test stabilization

* Add cmux-debug-windows skill with snapshot script and agent config

* Update project docs: mark browser parity and move/reorder phases complete, add parallel agent workflow guidelines

* Update bonsplit submodule: re-entrant setPosition guard, tab shortcut hints, and moveTab/reorderTab API

* Add browser agent UX improvements: snapshot refs, placement reuse, diagnostics, and skill docs

- Upgrade browser.snapshot to emit accessibility tree text with element refs (eN)
- Add right-sibling pane reuse policy for browser.open_split placement
- Add rich not_found diagnostics with retry logic for selector actions
- Support --snapshot-after for post-action verification on mutating commands
- Allow browser fill with empty text for clearing inputs
- Default CLI --id-format to refs-first (UUIDs opt-in via --id-format uuids|both)
- Format legacy new-pane/new-surface output with short surface refs
- Add skills/cmuxterm-browser/ and skills/cmuxterm/ end-user skill docs
- Add regression tests for placement policy, snapshot refs, diagnostics, and ID defaults

* Update bonsplit submodule: keep raster favicons in color when inactive
2026-02-13 16:45:31 -08:00

1413 lines
54 KiB
Swift

import Foundation
import SwiftUI
import Bonsplit
import Combine
struct SidebarStatusEntry {
let key: String
let value: String
let icon: String?
let color: String?
let timestamp: Date
}
enum SidebarLogLevel: String {
case info
case progress
case success
case warning
case error
}
struct SidebarLogEntry {
let message: String
let level: SidebarLogLevel
let source: String?
let timestamp: Date
}
struct SidebarProgressState {
let value: Double
let label: String?
}
struct SidebarGitBranchState {
let branch: String
let isDirty: Bool
}
/// Workspace represents a sidebar tab.
/// Each workspace contains one BonsplitController that manages split panes and nested surfaces.
@MainActor
final class Workspace: Identifiable, ObservableObject {
let id: UUID
@Published var title: String
@Published var customTitle: String?
@Published var isPinned: Bool = false
@Published var currentDirectory: String
/// The bonsplit controller managing the split panes for this workspace
let bonsplitController: BonsplitController
/// Mapping from bonsplit TabID to our Panel instances
@Published private(set) var panels: [UUID: any Panel] = [:]
/// Subscriptions for panel updates (e.g., browser title changes)
private var panelSubscriptions: [UUID: AnyCancellable] = [:]
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
private var isProgrammaticSplit = false
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
// layout/size synchronization.
/// The currently focused pane's panel ID
var focusedPanelId: UUID? {
guard let paneId = bonsplitController.focusedPaneId,
let tab = bonsplitController.selectedTab(inPane: paneId) else {
return nil
}
return panelIdFromSurfaceId(tab.id)
}
/// The currently focused terminal panel (if any)
var focusedTerminalPanel: TerminalPanel? {
guard let panelId = focusedPanelId,
let panel = panels[panelId] as? TerminalPanel else {
return nil
}
return panel
}
/// Published directory for each panel
@Published var panelDirectories: [UUID: String] = [:]
@Published var panelTitles: [UUID: String] = [:]
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: SidebarProgressState?
@Published var gitBranch: SidebarGitBranchState?
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
var focusedSurfaceId: UUID? { focusedPanelId }
var surfaceDirectories: [UUID: String] {
get { panelDirectories }
set { panelDirectories = newValue }
}
private var processTitle: String
// MARK: - Initialization
init(title: String = "Terminal", workingDirectory: String? = nil) {
self.id = UUID()
self.processTitle = title
self.title = title
self.customTitle = nil
let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty
self.currentDirectory = hasWorkingDirectory
? trimmedWorkingDirectory
: FileManager.default.homeDirectoryForCurrentUser.path
// Configure bonsplit with keepAllAlive to preserve terminal state
// Disable split animations for instant response
let appearance = BonsplitConfiguration.Appearance(
enableAnimations: false
)
let config = BonsplitConfiguration(
allowSplits: true,
allowCloseTabs: true,
allowCloseLastPane: false,
allowTabReordering: true,
allowCrossPaneTabMove: true,
autoCloseEmptyPanes: true,
contentViewLifecycle: .keepAllAlive,
newTabPosition: .current,
appearance: appearance
)
self.bonsplitController = BonsplitController(configuration: config)
// Remove the default "Welcome" tab that bonsplit creates
let welcomeTabIds = bonsplitController.allTabIds
// Create initial terminal panel
let terminalPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil
)
panels[terminalPanel.id] = terminalPanel
// Create initial tab in bonsplit and store the mapping
var initialTabId: TabID?
if let tabId = bonsplitController.createTab(
title: title,
icon: "terminal.fill",
isDirty: false
) {
surfaceIdToPanelId[tabId] = terminalPanel.id
initialTabId = tabId
}
// Close the default Welcome tab(s)
for welcomeTabId in welcomeTabIds {
bonsplitController.closeTab(welcomeTabId)
}
// Set ourselves as delegate
bonsplitController.delegate = self
// Ensure bonsplit has a focused pane and our didSelectTab handler runs for the
// initial terminal. bonsplit's createTab selects internally but does not emit
// didSelectTab, and focusedPaneId can otherwise be nil until user interaction.
if let initialTabId {
// Focus the pane containing the initial tab (or the first pane as fallback).
let paneToFocus: PaneID? = {
for paneId in bonsplitController.allPaneIds {
if bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == initialTabId }) {
return paneId
}
}
return bonsplitController.allPaneIds.first
}()
if let paneToFocus {
bonsplitController.focusPane(paneToFocus)
}
bonsplitController.selectTab(initialTabId)
}
}
// MARK: - Surface ID to Panel ID Mapping
/// Mapping from bonsplit TabID (surface ID) to panel UUID
private var surfaceIdToPanelId: [TabID: UUID] = [:]
/// 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.
private var forceCloseTabIds: Set<TabID> = []
/// Tab IDs that are currently showing (or about to show) a close confirmation prompt.
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set<TabID> = []
/// Deterministic tab selection to apply after a tab closes.
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
private var postCloseSelectTabId: [TabID: TabID] = [:]
private var isApplyingTabSelection = false
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
private var isReconcilingFocusState = false
private var focusReconcileScheduled = false
private var geometryReconcileScheduled = false
struct DetachedSurfaceTransfer {
let panelId: UUID
let panel: any Panel
let title: String
let icon: String?
let iconImageData: Data?
let isLoading: Bool
let directory: String?
let cachedTitle: String?
}
private var detachingTabIds: Set<TabID> = []
private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:]
func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? {
surfaceIdToPanelId[surfaceId]
}
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
surfaceIdToPanelId.first { $0.value == panelId }?.key
}
private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) {
let subscription = Publishers.CombineLatest3(
browserPanel.$pageTitle,
browserPanel.$isLoading.removeDuplicates(),
browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 })
)
.receive(on: DispatchQueue.main)
.sink { [weak self, weak browserPanel] _, isLoading, favicon in
guard let self = self,
let browserPanel = browserPanel,
let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return }
self.bonsplitController.updateTab(
tabId,
title: browserPanel.displayTitle,
iconImageData: .some(favicon),
isLoading: isLoading
)
}
panelSubscriptions[browserPanel.id] = subscription
}
// MARK: - Panel Access
func panel(for surfaceId: TabID) -> (any Panel)? {
guard let panelId = panelIdFromSurfaceId(surfaceId) else { return nil }
return panels[panelId]
}
func terminalPanel(for panelId: UUID) -> TerminalPanel? {
panels[panelId] as? TerminalPanel
}
func browserPanel(for panelId: UUID) -> BrowserPanel? {
panels[panelId] as? BrowserPanel
}
// MARK: - Title Management
var hasCustomTitle: Bool {
let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !trimmed.isEmpty
}
func applyProcessTitle(_ title: String) {
processTitle = title
guard customTitle == nil else { return }
self.title = title
}
func setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
customTitle = nil
self.title = processTitle
} else {
customTitle = trimmed
self.title = trimmed
}
}
// MARK: - Directory Updates
func updatePanelDirectory(panelId: UUID, directory: String) {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if panelDirectories[panelId] != trimmed {
panelDirectories[panelId] = trimmed
}
// Update current directory if this is the focused panel
if panelId == focusedPanelId {
currentDirectory = trimmed
}
}
func updatePanelTitle(panelId: UUID, title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if panelTitles[panelId] != trimmed {
panelTitles[panelId] = trimmed
}
// Update bonsplit tab title
if let tabId = surfaceIdFromPanelId(panelId) {
bonsplitController.updateTab(tabId, title: trimmed)
}
// If this is the only panel and no custom title, update workspace title
if panels.count == 1, customTitle == nil {
self.title = trimmed
processTitle = trimmed
}
}
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>) {
panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) }
panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
func recomputeListeningPorts() {
let unique = Set(surfaceListeningPorts.values.flatMap { $0 })
listeningPorts = unique.sorted()
}
// MARK: - Panel Operations
/// Create a new split with a terminal panel
@discardableResult
func newTerminalSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false
) -> TerminalPanel? {
// Get inherited config from the source terminal when possible.
// If the split is initiated from a non-terminal panel (for example browser),
// fall back to any terminal in the workspace.
let inheritedConfig: ghostty_surface_config_s? = {
if let sourceTerminal = terminalPanel(for: panelId),
let existing = sourceTerminal.surface.surface {
return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
if let fallbackSurface = panels.values
.compactMap({ ($0 as? TerminalPanel)?.surface.surface })
.first {
return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
return nil
}()
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig
)
panels[newPanel.id] = newPanel
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
// mutates layout state (avoids transient "Empty Panel" flashes during split).
let newTab = Bonsplit.Tab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
isDirty: newPanel.isDirty
)
surfaceIdToPanelId[newTab.id] = newPanel.id
// Create the split with the new tab already present in the new pane.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
panels.removeValue(forKey: newPanel.id)
surfaceIdToPanelId.removeValue(forKey: newTab.id)
return nil
}
// SplitViewController focuses the newly created pane, but the AppKit first responder can lag
// (or remain on the source surface) during SwiftUI/bonsplit structural updates. Explicitly
// focus the new panel so model focus + responder chain converge deterministically.
focusPanel(newPanel.id)
return newPanel
}
/// Create a new surface (nested tab) in the specified pane with a terminal panel.
/// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior),
/// true = force focus/selection of the new surface,
/// false = never focus (used for internal placeholder repair paths).
@discardableResult
func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
// Get an existing terminal panel to inherit config from
let inheritedConfig: ghostty_surface_config_s? = {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
let surface = terminalPanel.surface.surface {
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
}
}
return nil
}()
// Create new terminal panel
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig
)
panels[newPanel.id] = newPanel
// Create tab in bonsplit
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
isDirty: newPanel.isDirty,
inPane: paneId
) else {
panels.removeValue(forKey: newPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = newPanel.id
// bonsplit's createTab may not reliably emit didSelectTab, and its internal selection
// updates can be deferred. Force a deterministic selection + focus path so the new
// surface becomes interactive immediately (no "frozen until pane switch" state).
if shouldFocusNewTab {
// Use the same focus path as socket-driven focus changes so we reliably transfer
// AppKit first responder between terminal surfaces after heavy split/tab churn.
focusPanel(newPanel.id)
}
return newPanel
}
/// Create a new browser panel split
@discardableResult
func newBrowserSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil
) -> BrowserPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
// Create browser panel
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
panels[browserPanel.id] = browserPanel
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
title: browserPanel.displayTitle,
icon: browserPanel.displayIcon,
isDirty: browserPanel.isDirty,
isLoading: browserPanel.isLoading
)
surfaceIdToPanelId[newTab.id] = browserPanel.id
// Create the split with the browser tab already present.
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
surfaceIdToPanelId.removeValue(forKey: newTab.id)
panels.removeValue(forKey: browserPanel.id)
return nil
}
// See newTerminalSplit: explicitly focus the newly created panel so focus state is
// deterministic for both user and socket-driven workflows.
focusPanel(browserPanel.id)
installBrowserPanelSubscription(browserPanel)
return browserPanel
}
/// Create a new browser surface in the specified pane.
/// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior),
/// true = force focus/selection of the new surface,
/// false = never focus (used for internal placeholder repair paths).
@discardableResult
func newBrowserSurface(inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
panels[browserPanel.id] = browserPanel
guard let newTabId = bonsplitController.createTab(
title: browserPanel.displayTitle,
icon: browserPanel.displayIcon,
isDirty: browserPanel.isDirty,
isLoading: browserPanel.isLoading,
inPane: paneId
) else {
panels.removeValue(forKey: browserPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = browserPanel.id
// Match terminal behavior: enforce deterministic selection + focus.
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
browserPanel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
}
installBrowserPanelSubscription(browserPanel)
return browserPanel
}
/// Close a panel.
/// Returns true when a bonsplit tab close request was issued.
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
if let tabId = surfaceIdFromPanelId(panelId) {
if force {
forceCloseTabIds.insert(tabId)
}
// Close the tab in bonsplit (this triggers delegate callback)
return bonsplitController.closeTab(tabId)
}
// Mapping can transiently drift during split-tree mutations. If the target panel is
// currently focused, close whichever tab bonsplit marks selected in that focused pane.
guard focusedPanelId == panelId,
let focusedPane = bonsplitController.focusedPaneId,
let selected = bonsplitController.selectedTab(inPane: focusedPane) else {
return false
}
if force {
forceCloseTabIds.insert(selected.id)
}
return bonsplitController.closeTab(selected.id)
}
func paneId(forPanelId panelId: UUID) -> PaneID? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
return bonsplitController.allPaneIds.first { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId })
}
}
func indexInPane(forPanelId panelId: UUID) -> Int? {
guard let tabId = surfaceIdFromPanelId(panelId),
let paneId = paneId(forPanelId: panelId) else { return nil }
return bonsplitController.tabs(inPane: paneId).firstIndex(where: { $0.id == tabId })
}
/// Returns the nearest right-side sibling pane for browser placement.
/// The search is local to the source pane's ancestry in the split tree:
/// use the closest horizontal ancestor where the source is in the first (left) branch.
func preferredBrowserTargetPane(fromPanelId panelId: UUID) -> PaneID? {
guard let sourcePane = paneId(forPanelId: panelId) else { return nil }
let sourcePaneId = sourcePane.id.uuidString
let tree = bonsplitController.treeSnapshot()
guard let path = browserPathToPane(targetPaneId: sourcePaneId, node: tree) else { return nil }
let layout = bonsplitController.layoutSnapshot()
let paneFrameById = Dictionary(uniqueKeysWithValues: layout.panes.map { ($0.paneId, $0.frame) })
let sourceFrame = paneFrameById[sourcePaneId]
let sourceCenterY = sourceFrame.map { $0.y + ($0.height * 0.5) } ?? 0
let sourceRightX = sourceFrame.map { $0.x + $0.width } ?? 0
for crumb in path {
guard crumb.split.orientation == "horizontal", crumb.branch == .first else { continue }
var candidateNodes: [ExternalPaneNode] = []
browserCollectPaneNodes(node: crumb.split.second, into: &candidateNodes)
if candidateNodes.isEmpty { continue }
let sorted = candidateNodes.sorted { lhs, rhs in
let lhsDy = abs((lhs.frame.y + (lhs.frame.height * 0.5)) - sourceCenterY)
let rhsDy = abs((rhs.frame.y + (rhs.frame.height * 0.5)) - sourceCenterY)
if lhsDy != rhsDy { return lhsDy < rhsDy }
let lhsDx = abs(lhs.frame.x - sourceRightX)
let rhsDx = abs(rhs.frame.x - sourceRightX)
if lhsDx != rhsDx { return lhsDx < rhsDx }
if lhs.frame.x != rhs.frame.x { return lhs.frame.x < rhs.frame.x }
return lhs.id < rhs.id
}
for candidate in sorted {
guard let candidateUUID = UUID(uuidString: candidate.id),
candidateUUID != sourcePane.id,
let pane = bonsplitController.allPaneIds.first(where: { $0.id == candidateUUID }) else {
continue
}
return pane
}
}
return nil
}
private enum BrowserPaneBranch {
case first
case second
}
private struct BrowserPaneBreadcrumb {
let split: ExternalSplitNode
let branch: BrowserPaneBranch
}
private func browserPathToPane(targetPaneId: String, node: ExternalTreeNode) -> [BrowserPaneBreadcrumb]? {
switch node {
case .pane(let paneNode):
return paneNode.id == targetPaneId ? [] : nil
case .split(let splitNode):
if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.first) {
path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .first))
return path
}
if var path = browserPathToPane(targetPaneId: targetPaneId, node: splitNode.second) {
path.append(BrowserPaneBreadcrumb(split: splitNode, branch: .second))
return path
}
return nil
}
}
private func browserCollectPaneNodes(node: ExternalTreeNode, into output: inout [ExternalPaneNode]) {
switch node {
case .pane(let paneNode):
output.append(paneNode)
case .split(let splitNode):
browserCollectPaneNodes(node: splitNode.first, into: &output)
browserCollectPaneNodes(node: splitNode.second, into: &output)
}
}
@discardableResult
func moveSurface(panelId: UUID, toPane paneId: PaneID, atIndex index: Int? = nil, focus: Bool = true) -> Bool {
guard let tabId = surfaceIdFromPanelId(panelId) else { return false }
guard bonsplitController.allPaneIds.contains(paneId) else { return false }
guard bonsplitController.moveTab(tabId, toPane: paneId, atIndex: index) else { return false }
if focus {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(tabId)
focusPanel(panelId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
return true
}
@discardableResult
func reorderSurface(panelId: UUID, toIndex index: Int) -> Bool {
guard let tabId = surfaceIdFromPanelId(panelId) else { return false }
guard bonsplitController.reorderTab(tabId, toIndex: index) else { return false }
if let paneId = paneId(forPanelId: panelId) {
applyTabSelection(tabId: tabId, inPane: paneId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
return true
}
func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
guard panels[panelId] != nil else { return nil }
detachingTabIds.insert(tabId)
forceCloseTabIds.insert(tabId)
guard bonsplitController.closeTab(tabId) else {
detachingTabIds.remove(tabId)
pendingDetachedSurfaces.removeValue(forKey: tabId)
forceCloseTabIds.remove(tabId)
return nil
}
return pendingDetachedSurfaces.removeValue(forKey: tabId)
}
@discardableResult
func attachDetachedSurface(
_ detached: DetachedSurfaceTransfer,
inPane paneId: PaneID,
atIndex index: Int? = nil,
focus: Bool = true
) -> UUID? {
guard bonsplitController.allPaneIds.contains(paneId) else { return nil }
guard panels[detached.panelId] == nil else { return nil }
panels[detached.panelId] = detached.panel
if let terminalPanel = detached.panel as? TerminalPanel {
terminalPanel.updateWorkspaceId(id)
} else if let browserPanel = detached.panel as? BrowserPanel {
browserPanel.updateWorkspaceId(id)
installBrowserPanelSubscription(browserPanel)
}
if let directory = detached.directory {
panelDirectories[detached.panelId] = directory
}
if let cachedTitle = detached.cachedTitle {
panelTitles[detached.panelId] = cachedTitle
}
guard let newTabId = bonsplitController.createTab(
title: detached.title,
icon: detached.icon,
iconImageData: detached.iconImageData,
isDirty: detached.panel.isDirty,
isLoading: detached.isLoading,
inPane: paneId
) else {
panels.removeValue(forKey: detached.panelId)
panelDirectories.removeValue(forKey: detached.panelId)
panelTitles.removeValue(forKey: detached.panelId)
panelSubscriptions.removeValue(forKey: detached.panelId)
return nil
}
surfaceIdToPanelId[newTabId] = detached.panelId
if let index {
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
}
if focus {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
detached.panel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
} else {
scheduleFocusReconcile()
}
scheduleTerminalGeometryReconcile()
return detached.panelId
}
// MARK: - Focus Management
func focusPanel(_ panelId: UUID) {
#if DEBUG
let pane = bonsplitController.focusedPaneId?.id.uuidString ?? "nil"
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
#endif
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
let currentlyFocusedPanelId = focusedPanelId
// Capture the currently focused terminal view so we can explicitly move AppKit first
// responder when focusing another terminal (helps avoid "highlighted but typing goes to
// another pane" after heavy split/tab mutations).
let previousTerminalHostedView = focusedTerminalPanel?.hostedView
// `selectTab` does not necessarily move bonsplit's focused pane. For programmatic focus
// (socket API, notification click, etc.), ensure the target tab's pane becomes focused
// so `focusedPanelId` and follow-on focus logic are coherent.
let targetPaneId = bonsplitController.allPaneIds.first(where: { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabId })
})
let selectionAlreadyConverged: Bool = {
guard let targetPaneId else { return false }
return bonsplitController.focusedPaneId == targetPaneId &&
bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId
}()
if let targetPaneId, !selectionAlreadyConverged {
bonsplitController.focusPane(targetPaneId)
}
if !selectionAlreadyConverged {
bonsplitController.selectTab(tabId)
}
// Also focus the underlying panel
if let panel = panels[panelId] {
if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged {
panel.focus()
}
if let terminalPanel = panel as? TerminalPanel {
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
// (becomeFirstResponder -> onFocus -> focusPanel).
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
}
}
if let targetPaneId {
applyTabSelection(tabId: tabId, inPane: targetPaneId)
}
}
func moveFocus(direction: NavigationDirection) {
// Unfocus the currently-focused panel before navigating.
if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] {
prev.unfocus()
}
bonsplitController.navigateFocus(direction: direction)
// Always reconcile selection/focus after navigation so AppKit first-responder and
// bonsplit's focused pane stay aligned, even through split tree mutations.
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
// MARK: - Surface Navigation
/// Select the next surface in the currently focused pane
func selectNextSurface() {
bonsplitController.selectNextTab()
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
/// Select the previous surface in the currently focused pane
func selectPreviousSurface() {
bonsplitController.selectPreviousTab()
if let paneId = bonsplitController.focusedPaneId,
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
applyTabSelection(tabId: tabId, inPane: paneId)
}
}
/// Select a surface by index in the currently focused pane
func selectSurface(at index: Int) {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
guard index >= 0 && index < tabs.count else { return }
bonsplitController.selectTab(tabs[index].id)
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
}
}
/// Select the last surface in the currently focused pane
func selectLastSurface() {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
guard let last = tabs.last else { return }
bonsplitController.selectTab(last.id)
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
}
}
/// Create a new terminal surface in the currently focused pane
@discardableResult
func newTerminalSurfaceInFocusedPane() -> TerminalPanel? {
guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil }
return newTerminalSurface(inPane: focusedPaneId)
}
// MARK: - Flash/Notification Support
func triggerFocusFlash(panelId: UUID) {
if let terminalPanel = terminalPanel(for: panelId) {
terminalPanel.triggerFlash()
return
}
if let browserPanel = browserPanel(for: panelId) {
browserPanel.triggerFlash()
return
}
}
func triggerNotificationFocusFlash(
panelId: UUID,
requiresSplit: Bool = false,
shouldFocus: Bool = true
) {
guard let terminalPanel = terminalPanel(for: panelId) else { return }
if shouldFocus {
focusPanel(panelId)
}
let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1
if requiresSplit && !isSplit {
return
}
terminalPanel.triggerFlash()
}
func triggerDebugFlash(panelId: UUID) {
triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true)
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)
@discardableResult
func createReplacementTerminalPanel() -> TerminalPanel {
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_TAB,
configTemplate: nil
)
panels[newPanel.id] = newPanel
// Create tab in bonsplit
if let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
isDirty: newPanel.isDirty
) {
surfaceIdToPanelId[newTabId] = newPanel.id
}
return newPanel
}
/// Check if any panel needs close confirmation
func needsConfirmClose() -> Bool {
for panel in panels.values {
if let terminalPanel = panel as? TerminalPanel,
terminalPanel.needsConfirmClose() {
return true
}
}
return false
}
private func reconcileFocusState() {
guard !isReconcilingFocusState else { return }
isReconcilingFocusState = true
defer { isReconcilingFocusState = false }
// Source of truth: bonsplit focused pane + selected tab.
// AppKit first responder must converge to this model state, not the other way around.
var targetPanelId: UUID?
if let focusedPane = bonsplitController.focusedPaneId,
let focusedTab = bonsplitController.selectedTab(inPane: focusedPane),
let mappedPanelId = panelIdFromSurfaceId(focusedTab.id),
panels[mappedPanelId] != nil {
targetPanelId = mappedPanelId
} else {
for pane in bonsplitController.allPaneIds {
guard let selectedTab = bonsplitController.selectedTab(inPane: pane),
let mappedPanelId = panelIdFromSurfaceId(selectedTab.id),
panels[mappedPanelId] != nil else { continue }
bonsplitController.focusPane(pane)
bonsplitController.selectTab(selectedTab.id)
targetPanelId = mappedPanelId
break
}
}
if targetPanelId == nil, let fallbackPanelId = panels.keys.first {
targetPanelId = fallbackPanelId
if let fallbackTabId = surfaceIdFromPanelId(fallbackPanelId),
let fallbackPane = bonsplitController.allPaneIds.first(where: { paneId in
bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == fallbackTabId })
}) {
bonsplitController.focusPane(fallbackPane)
bonsplitController.selectTab(fallbackTabId)
}
}
guard let targetPanelId, let targetPanel = panels[targetPanelId] else { return }
for (panelId, panel) in panels where panelId != targetPanelId {
panel.unfocus()
}
targetPanel.focus()
if let terminalPanel = targetPanel as? TerminalPanel {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: targetPanelId)
}
}
/// Reconcile focus/first-responder convergence.
/// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first.
private func scheduleFocusReconcile() {
guard !focusReconcileScheduled else { return }
focusReconcileScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.focusReconcileScheduled = false
self.reconcileFocusState()
}
}
/// Reconcile remaining terminal view geometries after split topology changes.
/// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn.
private func scheduleTerminalGeometryReconcile() {
guard !geometryReconcileScheduled else { return }
geometryReconcileScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.geometryReconcileScheduled = false
for panel in self.panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
terminalPanel.hostedView.reconcileGeometryNow()
terminalPanel.surface.forceRefresh()
}
}
}
}
// MARK: - BonsplitDelegate
extension Workspace: BonsplitDelegate {
@MainActor
private func confirmClosePanel(for tabId: TabID) async -> Bool {
let alert = NSAlert()
alert.messageText = "Close tab?"
alert.informativeText = "This will close the current tab."
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
return await withCheckedContinuation { continuation in
alert.beginSheetModal(for: window) { response in
continuation.resume(returning: response == .alertFirstButtonReturn)
}
}
}
return alert.runModal() == .alertFirstButtonReturn
}
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
private func applyTabSelection(tabId: TabID, inPane pane: PaneID) {
pendingTabSelection = (tabId: tabId, pane: pane)
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
defer {
isApplyingTabSelection = false
pendingTabSelection = nil
}
var iterations = 0
while let request = pendingTabSelection {
pendingTabSelection = nil
iterations += 1
if iterations > 8 { break }
applyTabSelectionNow(tabId: request.tabId, inPane: request.pane)
}
}
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
bonsplitController.focusPane(pane)
}
if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }),
bonsplitController.selectedTab(inPane: pane)?.id != tabId {
bonsplitController.selectTab(tabId)
}
}
let focusedPane: PaneID
let selectedTabId: TabID
if let currentPane = bonsplitController.focusedPaneId,
let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id {
focusedPane = currentPane
selectedTabId = currentTabId
} else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) {
focusedPane = pane
selectedTabId = tabId
bonsplitController.focusPane(focusedPane)
bonsplitController.selectTab(selectedTabId)
} else {
return
}
// Focus the selected panel
guard let panelId = panelIdFromSurfaceId(selectedTabId),
let panel = panels[panelId] else {
return
}
// Unfocus all other panels
for (id, p) in panels where id != panelId {
p.unfocus()
}
panel.focus()
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
}
// Update current directory if this is a terminal
if let dir = panelDirectories[panelId] {
currentDirectory = dir
}
// Post notification
NotificationCenter.default.post(
name: .ghosttyDidFocusSurface,
object: nil,
userInfo: [
GhosttyNotificationKey.tabId: self.id,
GhosttyNotificationKey.surfaceId: panelId
]
)
}
func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool {
func recordPostCloseSelection() {
let tabs = controller.tabs(inPane: pane)
guard let idx = tabs.firstIndex(where: { $0.id == tab.id }) else {
postCloseSelectTabId.removeValue(forKey: tab.id)
return
}
let target: TabID? = {
if idx + 1 < tabs.count { return tabs[idx + 1].id }
if idx > 0 { return tabs[idx - 1].id }
return nil
}()
if let target {
postCloseSelectTabId[tab.id] = target
} else {
postCloseSelectTabId.removeValue(forKey: tab.id)
}
}
if forceCloseTabIds.contains(tab.id) {
recordPostCloseSelection()
return true
}
// Check if the panel needs close confirmation
guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else {
recordPostCloseSelection()
return true
}
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
// this gating on the second pass.
if terminalPanel.needsConfirmClose() {
if pendingCloseConfirmTabIds.contains(tab.id) {
return false
}
pendingCloseConfirmTabIds.insert(tab.id)
let tabId = tab.id
DispatchQueue.main.async { [weak self] in
guard let self else { return }
Task { @MainActor in
defer { self.pendingCloseConfirmTabIds.remove(tabId) }
// If the tab disappeared while we were scheduling, do nothing.
guard self.panelIdFromSurfaceId(tabId) != nil else { return }
let confirmed = await self.confirmClosePanel(for: tabId)
guard confirmed else { return }
self.forceCloseTabIds.insert(tabId)
self.bonsplitController.closeTab(tabId)
}
}
return false
}
recordPostCloseSelection()
return true
}
func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) {
forceCloseTabIds.remove(tabId)
let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId)
// Clean up our panel
guard let panelId = panelIdFromSurfaceId(tabId) else {
#if DEBUG
NSLog("[Workspace] didCloseTab: no panelId for tabId")
#endif
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
return
}
#if DEBUG
NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)")
#endif
let isDetaching = detachingTabIds.remove(tabId) != nil
let panel = panels[panelId]
if isDetaching, let panel {
let browserPanel = panel as? BrowserPanel
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
panelId: panelId,
panel: panel,
title: panel.displayTitle,
icon: panel.displayIcon,
iconImageData: browserPanel?.faviconPNGData,
isLoading: browserPanel?.isLoading ?? false,
directory: panelDirectories[panelId],
cachedTitle: panelTitles[panelId]
)
} else {
panel?.close()
}
panels.removeValue(forKey: panelId)
surfaceIdToPanelId.removeValue(forKey: tabId)
panelDirectories.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
// Keep the workspace invariant: always retain at least one real panel.
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
if panels.isEmpty {
let replacement = createReplacementTerminalPanel()
if let replacementTabId = surfaceIdFromPanelId(replacement.id),
let replacementPane = bonsplitController.allPaneIds.first {
bonsplitController.focusPane(replacementPane)
bonsplitController.selectTab(replacementTabId)
applyTabSelection(tabId: replacementTabId, inPane: replacementPane)
}
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
return
}
if let selectTabId,
bonsplitController.allPaneIds.contains(pane),
bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }),
bonsplitController.focusedPaneId == pane {
// Keep selection/focus convergence in the same close transaction to avoid a transient
// frame where the pane has no selected content.
bonsplitController.selectTab(selectTabId)
applyTabSelection(tabId: selectTabId, inPane: pane)
}
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) {
applyTabSelection(tabId: tab.id, inPane: pane)
}
func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) {
_ = source
applyTabSelection(tabId: tab.id, inPane: destination)
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) {
// When a pane is focused, focus its selected tab's panel
guard let tab = controller.selectedTab(inPane: pane) else { return }
#if DEBUG
FocusLogStore.shared.append(
"Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")"
)
#endif
applyTabSelection(tabId: tab.id, inPane: pane)
// Apply window background for terminal
if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = panels[panelId] as? TerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
}
}
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
_ = paneId
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool {
// Check if any panel in this pane needs close confirmation
let tabs = controller.tabs(inPane: pane)
for tab in tabs {
if forceCloseTabIds.contains(tab.id) { continue }
if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId),
terminalPanel.needsConfirmClose() {
return false
}
}
return true
}
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {
// Only auto-create a terminal if the split came from bonsplit UI.
// Programmatic splits via newTerminalSplit() set isProgrammaticSplit and handle their own panels.
guard !isProgrammaticSplit else {
scheduleTerminalGeometryReconcile()
return
}
// If the new pane already has a tab, this split moved an existing tab (drag-to-split).
//
// In the "drag the only tab to split edge" case, bonsplit inserts a placeholder "Empty"
// tab in the source pane to avoid leaving it tabless. In cmux, this is undesirable:
// it creates a pane with no real surfaces and leaves an "Empty" tab in the tab bar.
//
// Replace placeholder-only source panes with a real terminal surface, then drop the
// placeholder tabs so the UI stays consistent and pane lists don't contain empties.
if !controller.tabs(inPane: newPane).isEmpty {
let originalTabs = controller.tabs(inPane: originalPane)
let hasRealSurface = originalTabs.contains { panelIdFromSurfaceId($0.id) != nil }
if !hasRealSurface {
_ = newTerminalSurface(inPane: originalPane, focus: false)
for tab in controller.tabs(inPane: originalPane) {
if panelIdFromSurfaceId(tab.id) == nil {
bonsplitController.closeTab(tab.id)
}
}
}
scheduleTerminalGeometryReconcile()
return
}
// Get the focused terminal in the original pane to inherit config from
guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id,
let sourcePanelId = panelIdFromSurfaceId(sourceTabId),
let sourcePanel = terminalPanel(for: sourcePanelId) else { return }
let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface {
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
} else {
nil
}
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig
)
panels[newPanel.id] = newPanel
guard let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
isDirty: newPanel.isDirty,
inPane: newPane
) else {
panels.removeValue(forKey: newPanel.id)
return
}
surfaceIdToPanelId[newTabId] = newPanel.id
// `createTab` selects the new tab but does not emit didSelectTab; schedule an explicit
// selection so our focus/unfocus logic runs after this delegate callback returns.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.bonsplitController.focusedPaneId == newPane {
self.bonsplitController.selectTab(newTabId)
}
self.scheduleTerminalGeometryReconcile()
self.scheduleFocusReconcile()
}
}
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {
_ = snapshot
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
// No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.
}