* Fix orphaned child processes when closing workspace tabs When closing a workspace tab via the sidebar X button, child processes (login → zsh → claude) survived as orphans because TabManager.closeWorkspace() only removed the workspace from the tabs array without explicitly freeing Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views and Combine publishers held references, delaying or preventing ghostty_surface_free() (which sends SIGHUP) from ever running. This adds explicit teardown on the workspace close path: - TerminalSurface.teardownSurface(): idempotent method to free the Ghostty runtime surface eagerly, matching the existing deinit logic - TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent - Workspace.teardownAllPanels() iterates all panels and closes them - TabManager.closeWorkspace() calls teardownAllPanels() before removing the workspace from the tabs array * Harden workspace teardown and ownership checks * Address follow-up teardown review feedback --------- Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
4490 lines
179 KiB
Swift
4490 lines
179 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Bonsplit
|
|
import Combine
|
|
import CoreText
|
|
|
|
func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
|
|
switch context {
|
|
case GHOSTTY_SURFACE_CONTEXT_WINDOW:
|
|
return "window"
|
|
case GHOSTTY_SURFACE_CONTEXT_TAB:
|
|
return "tab"
|
|
case GHOSTTY_SURFACE_CONTEXT_SPLIT:
|
|
return "split"
|
|
default:
|
|
return "unknown(\(context))"
|
|
}
|
|
}
|
|
|
|
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
|
|
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
|
|
return nil
|
|
}
|
|
|
|
let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue()
|
|
let points = Float(CTFontGetSize(ctFont))
|
|
guard points > 0 else { return nil }
|
|
return points
|
|
}
|
|
|
|
func cmuxInheritedSurfaceConfig(
|
|
sourceSurface: ghostty_surface_t,
|
|
context: ghostty_surface_context_e
|
|
) -> ghostty_surface_config_s {
|
|
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
|
|
var config = inherited
|
|
|
|
// Make runtime zoom inheritance explicit, even when Ghostty's
|
|
// inherit-font-size config is disabled.
|
|
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
|
|
if let points = runtimePoints {
|
|
config.font_size = points
|
|
}
|
|
|
|
#if DEBUG
|
|
let inheritedText = String(format: "%.2f", inherited.font_size)
|
|
let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil"
|
|
let finalText = String(format: "%.2f", config.font_size)
|
|
dlog(
|
|
"zoom.inherit context=\(cmuxSurfaceContextName(context)) " +
|
|
"inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)"
|
|
)
|
|
#endif
|
|
|
|
return config
|
|
}
|
|
|
|
struct SidebarStatusEntry {
|
|
let key: String
|
|
let value: String
|
|
let icon: String?
|
|
let color: String?
|
|
let url: URL?
|
|
let priority: Int
|
|
let format: SidebarMetadataFormat
|
|
let timestamp: Date
|
|
|
|
init(
|
|
key: String,
|
|
value: String,
|
|
icon: String? = nil,
|
|
color: String? = nil,
|
|
url: URL? = nil,
|
|
priority: Int = 0,
|
|
format: SidebarMetadataFormat = .plain,
|
|
timestamp: Date = Date()
|
|
) {
|
|
self.key = key
|
|
self.value = value
|
|
self.icon = icon
|
|
self.color = color
|
|
self.url = url
|
|
self.priority = priority
|
|
self.format = format
|
|
self.timestamp = timestamp
|
|
}
|
|
}
|
|
|
|
struct SidebarMetadataBlock {
|
|
let key: String
|
|
let markdown: String
|
|
let priority: Int
|
|
let timestamp: Date
|
|
}
|
|
|
|
enum SidebarMetadataFormat: String {
|
|
case plain
|
|
case markdown
|
|
}
|
|
|
|
private struct SessionPaneRestoreEntry {
|
|
let paneId: PaneID
|
|
let snapshot: SessionPaneLayoutSnapshot
|
|
}
|
|
|
|
extension Workspace {
|
|
func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot {
|
|
let tree = bonsplitController.treeSnapshot()
|
|
let layout = sessionLayoutSnapshot(from: tree)
|
|
|
|
let orderedPanelIds = sidebarOrderedPanelIds()
|
|
var seen: Set<UUID> = []
|
|
var allPanelIds: [UUID] = []
|
|
for panelId in orderedPanelIds where seen.insert(panelId).inserted {
|
|
allPanelIds.append(panelId)
|
|
}
|
|
for panelId in panels.keys.sorted(by: { $0.uuidString < $1.uuidString }) where seen.insert(panelId).inserted {
|
|
allPanelIds.append(panelId)
|
|
}
|
|
|
|
let panelSnapshots = allPanelIds
|
|
.prefix(SessionPersistencePolicy.maxPanelsPerWorkspace)
|
|
.compactMap { sessionPanelSnapshot(panelId: $0, includeScrollback: includeScrollback) }
|
|
|
|
let statusSnapshots = statusEntries.values
|
|
.sorted { lhs, rhs in lhs.key < rhs.key }
|
|
.map { entry in
|
|
SessionStatusEntrySnapshot(
|
|
key: entry.key,
|
|
value: entry.value,
|
|
icon: entry.icon,
|
|
color: entry.color,
|
|
timestamp: entry.timestamp.timeIntervalSince1970
|
|
)
|
|
}
|
|
let logSnapshots = logEntries.map { entry in
|
|
SessionLogEntrySnapshot(
|
|
message: entry.message,
|
|
level: entry.level.rawValue,
|
|
source: entry.source,
|
|
timestamp: entry.timestamp.timeIntervalSince1970
|
|
)
|
|
}
|
|
|
|
let progressSnapshot = progress.map { progress in
|
|
SessionProgressSnapshot(value: progress.value, label: progress.label)
|
|
}
|
|
let gitBranchSnapshot = gitBranch.map { branch in
|
|
SessionGitBranchSnapshot(branch: branch.branch, isDirty: branch.isDirty)
|
|
}
|
|
|
|
return SessionWorkspaceSnapshot(
|
|
processTitle: processTitle,
|
|
customTitle: customTitle,
|
|
customColor: customColor,
|
|
isPinned: isPinned,
|
|
currentDirectory: currentDirectory,
|
|
focusedPanelId: focusedPanelId,
|
|
layout: layout,
|
|
panels: panelSnapshots,
|
|
statusEntries: statusSnapshots,
|
|
logEntries: logSnapshots,
|
|
progress: progressSnapshot,
|
|
gitBranch: gitBranchSnapshot
|
|
)
|
|
}
|
|
|
|
func restoreSessionSnapshot(_ snapshot: SessionWorkspaceSnapshot) {
|
|
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
|
|
|
|
let normalizedCurrentDirectory = snapshot.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !normalizedCurrentDirectory.isEmpty {
|
|
currentDirectory = normalizedCurrentDirectory
|
|
}
|
|
|
|
let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) })
|
|
let leafEntries = restoreSessionLayout(snapshot.layout)
|
|
var oldToNewPanelIds: [UUID: UUID] = [:]
|
|
|
|
for entry in leafEntries {
|
|
restorePane(
|
|
entry.paneId,
|
|
snapshot: entry.snapshot,
|
|
panelSnapshotsById: panelSnapshotsById,
|
|
oldToNewPanelIds: &oldToNewPanelIds
|
|
)
|
|
}
|
|
|
|
pruneSurfaceMetadata(validSurfaceIds: Set(panels.keys))
|
|
applySessionDividerPositions(snapshotNode: snapshot.layout, liveNode: bonsplitController.treeSnapshot())
|
|
|
|
applyProcessTitle(snapshot.processTitle)
|
|
setCustomTitle(snapshot.customTitle)
|
|
setCustomColor(snapshot.customColor)
|
|
isPinned = snapshot.isPinned
|
|
|
|
statusEntries = Dictionary(
|
|
uniqueKeysWithValues: snapshot.statusEntries.map { entry in
|
|
(
|
|
entry.key,
|
|
SidebarStatusEntry(
|
|
key: entry.key,
|
|
value: entry.value,
|
|
icon: entry.icon,
|
|
color: entry.color,
|
|
timestamp: Date(timeIntervalSince1970: entry.timestamp)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
logEntries = snapshot.logEntries.map { entry in
|
|
SidebarLogEntry(
|
|
message: entry.message,
|
|
level: SidebarLogLevel(rawValue: entry.level) ?? .info,
|
|
source: entry.source,
|
|
timestamp: Date(timeIntervalSince1970: entry.timestamp)
|
|
)
|
|
}
|
|
progress = snapshot.progress.map { SidebarProgressState(value: $0.value, label: $0.label) }
|
|
gitBranch = snapshot.gitBranch.map { SidebarGitBranchState(branch: $0.branch, isDirty: $0.isDirty) }
|
|
|
|
recomputeListeningPorts()
|
|
|
|
if let focusedOldPanelId = snapshot.focusedPanelId,
|
|
let focusedNewPanelId = oldToNewPanelIds[focusedOldPanelId],
|
|
panels[focusedNewPanelId] != nil {
|
|
focusPanel(focusedNewPanelId)
|
|
} else if let fallbackFocusedPanelId = focusedPanelId, panels[fallbackFocusedPanelId] != nil {
|
|
focusPanel(fallbackFocusedPanelId)
|
|
} else {
|
|
scheduleFocusReconcile()
|
|
}
|
|
}
|
|
|
|
private func sessionLayoutSnapshot(from node: ExternalTreeNode) -> SessionWorkspaceLayoutSnapshot {
|
|
switch node {
|
|
case .pane(let pane):
|
|
let panelIds = sessionPanelIDs(for: pane)
|
|
let selectedPanelId = pane.selectedTabId.flatMap(sessionPanelID(forExternalTabIDString:))
|
|
return .pane(
|
|
SessionPaneLayoutSnapshot(
|
|
panelIds: panelIds,
|
|
selectedPanelId: selectedPanelId
|
|
)
|
|
)
|
|
case .split(let split):
|
|
return .split(
|
|
SessionSplitLayoutSnapshot(
|
|
orientation: split.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
|
|
dividerPosition: split.dividerPosition,
|
|
first: sessionLayoutSnapshot(from: split.first),
|
|
second: sessionLayoutSnapshot(from: split.second)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func sessionPanelIDs(for pane: ExternalPaneNode) -> [UUID] {
|
|
var panelIds: [UUID] = []
|
|
var seen = Set<UUID>()
|
|
for tab in pane.tabs {
|
|
guard let panelId = sessionPanelID(forExternalTabIDString: tab.id) else { continue }
|
|
if seen.insert(panelId).inserted {
|
|
panelIds.append(panelId)
|
|
}
|
|
}
|
|
return panelIds
|
|
}
|
|
|
|
private func sessionPanelID(forExternalTabIDString tabIDString: String) -> UUID? {
|
|
guard let tabUUID = UUID(uuidString: tabIDString) else { return nil }
|
|
for (surfaceId, panelId) in surfaceIdToPanelId {
|
|
guard let surfaceUUID = sessionSurfaceUUID(for: surfaceId) else { continue }
|
|
if surfaceUUID == tabUUID {
|
|
return panelId
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func sessionSurfaceUUID(for surfaceId: TabID) -> UUID? {
|
|
struct EncodedSurfaceID: Decodable {
|
|
let id: UUID
|
|
}
|
|
|
|
guard let data = try? JSONEncoder().encode(surfaceId),
|
|
let decoded = try? JSONDecoder().decode(EncodedSurfaceID.self, from: data) else {
|
|
return nil
|
|
}
|
|
return decoded.id
|
|
}
|
|
|
|
private func sessionPanelSnapshot(panelId: UUID, includeScrollback: Bool) -> SessionPanelSnapshot? {
|
|
guard let panel = panels[panelId] else { return nil }
|
|
|
|
let panelTitle = panelTitle(panelId: panelId)
|
|
let customTitle = panelCustomTitles[panelId]
|
|
let directory = panelDirectories[panelId]
|
|
let isPinned = pinnedPanelIds.contains(panelId)
|
|
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
|
|
let branchSnapshot = panelGitBranches[panelId].map {
|
|
SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty)
|
|
}
|
|
let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted()
|
|
let ttyName = surfaceTTYNames[panelId]
|
|
|
|
let terminalSnapshot: SessionTerminalPanelSnapshot?
|
|
let browserSnapshot: SessionBrowserPanelSnapshot?
|
|
let markdownSnapshot: SessionMarkdownPanelSnapshot?
|
|
switch panel.panelType {
|
|
case .terminal:
|
|
guard let terminalPanel = panel as? TerminalPanel else { return nil }
|
|
let capturedScrollback = includeScrollback
|
|
? TerminalController.shared.readTerminalTextForSnapshot(
|
|
terminalPanel: terminalPanel,
|
|
includeScrollback: true,
|
|
lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal
|
|
)
|
|
: nil
|
|
let resolvedScrollback = terminalSnapshotScrollback(
|
|
panelId: panelId,
|
|
capturedScrollback: capturedScrollback,
|
|
includeScrollback: includeScrollback
|
|
)
|
|
terminalSnapshot = SessionTerminalPanelSnapshot(
|
|
workingDirectory: panelDirectories[panelId],
|
|
scrollback: resolvedScrollback
|
|
)
|
|
browserSnapshot = nil
|
|
markdownSnapshot = nil
|
|
case .browser:
|
|
guard let browserPanel = panel as? BrowserPanel else { return nil }
|
|
terminalSnapshot = nil
|
|
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
|
|
browserSnapshot = SessionBrowserPanelSnapshot(
|
|
urlString: browserPanel.preferredURLStringForOmnibar(),
|
|
shouldRenderWebView: browserPanel.shouldRenderWebView,
|
|
pageZoom: Double(browserPanel.webView.pageZoom),
|
|
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
|
|
backHistoryURLStrings: historySnapshot.backHistoryURLStrings,
|
|
forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings
|
|
)
|
|
markdownSnapshot = nil
|
|
case .markdown:
|
|
guard let mdPanel = panel as? MarkdownPanel else { return nil }
|
|
terminalSnapshot = nil
|
|
browserSnapshot = nil
|
|
markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath)
|
|
}
|
|
|
|
return SessionPanelSnapshot(
|
|
id: panelId,
|
|
type: panel.panelType,
|
|
title: panelTitle,
|
|
customTitle: customTitle,
|
|
directory: directory,
|
|
isPinned: isPinned,
|
|
isManuallyUnread: isManuallyUnread,
|
|
gitBranch: branchSnapshot,
|
|
listeningPorts: listeningPorts,
|
|
ttyName: ttyName,
|
|
terminal: terminalSnapshot,
|
|
browser: browserSnapshot,
|
|
markdown: markdownSnapshot
|
|
)
|
|
}
|
|
|
|
nonisolated static func resolvedSnapshotTerminalScrollback(
|
|
capturedScrollback: String?,
|
|
fallbackScrollback: String?
|
|
) -> String? {
|
|
if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) {
|
|
return captured
|
|
}
|
|
return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback)
|
|
}
|
|
|
|
private func terminalSnapshotScrollback(
|
|
panelId: UUID,
|
|
capturedScrollback: String?,
|
|
includeScrollback: Bool
|
|
) -> String? {
|
|
guard includeScrollback else { return nil }
|
|
let fallback = restoredTerminalScrollbackByPanelId[panelId]
|
|
let resolved = Self.resolvedSnapshotTerminalScrollback(
|
|
capturedScrollback: capturedScrollback,
|
|
fallbackScrollback: fallback
|
|
)
|
|
if let resolved {
|
|
restoredTerminalScrollbackByPanelId[panelId] = resolved
|
|
} else {
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private func restoreSessionLayout(_ layout: SessionWorkspaceLayoutSnapshot) -> [SessionPaneRestoreEntry] {
|
|
guard let rootPaneId = bonsplitController.allPaneIds.first else {
|
|
return []
|
|
}
|
|
|
|
var leaves: [SessionPaneRestoreEntry] = []
|
|
restoreSessionLayoutNode(layout, inPane: rootPaneId, leaves: &leaves)
|
|
return leaves
|
|
}
|
|
|
|
private func restoreSessionLayoutNode(
|
|
_ node: SessionWorkspaceLayoutSnapshot,
|
|
inPane paneId: PaneID,
|
|
leaves: inout [SessionPaneRestoreEntry]
|
|
) {
|
|
switch node {
|
|
case .pane(let pane):
|
|
leaves.append(SessionPaneRestoreEntry(paneId: paneId, snapshot: pane))
|
|
case .split(let split):
|
|
var anchorPanelId = bonsplitController
|
|
.tabs(inPane: paneId)
|
|
.compactMap { panelIdFromSurfaceId($0.id) }
|
|
.first
|
|
|
|
if anchorPanelId == nil {
|
|
anchorPanelId = newTerminalSurface(inPane: paneId, focus: false)?.id
|
|
}
|
|
|
|
guard let anchorPanelId,
|
|
let newSplitPanel = newTerminalSplit(
|
|
from: anchorPanelId,
|
|
orientation: split.orientation.splitOrientation,
|
|
insertFirst: false,
|
|
focus: false
|
|
),
|
|
let secondPaneId = self.paneId(forPanelId: newSplitPanel.id) else {
|
|
leaves.append(
|
|
SessionPaneRestoreEntry(
|
|
paneId: paneId,
|
|
snapshot: SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
restoreSessionLayoutNode(split.first, inPane: paneId, leaves: &leaves)
|
|
restoreSessionLayoutNode(split.second, inPane: secondPaneId, leaves: &leaves)
|
|
}
|
|
}
|
|
|
|
private func restorePane(
|
|
_ paneId: PaneID,
|
|
snapshot: SessionPaneLayoutSnapshot,
|
|
panelSnapshotsById: [UUID: SessionPanelSnapshot],
|
|
oldToNewPanelIds: inout [UUID: UUID]
|
|
) {
|
|
let existingPanelIds = bonsplitController
|
|
.tabs(inPane: paneId)
|
|
.compactMap { panelIdFromSurfaceId($0.id) }
|
|
let desiredOldPanelIds = snapshot.panelIds.filter { panelSnapshotsById[$0] != nil }
|
|
|
|
var createdPanelIds: [UUID] = []
|
|
for oldPanelId in desiredOldPanelIds {
|
|
guard let panelSnapshot = panelSnapshotsById[oldPanelId] else { continue }
|
|
guard let createdPanelId = createPanel(from: panelSnapshot, inPane: paneId) else { continue }
|
|
createdPanelIds.append(createdPanelId)
|
|
oldToNewPanelIds[oldPanelId] = createdPanelId
|
|
}
|
|
|
|
guard !createdPanelIds.isEmpty else { return }
|
|
|
|
for oldPanelId in existingPanelIds where !createdPanelIds.contains(oldPanelId) {
|
|
_ = closePanel(oldPanelId, force: true)
|
|
}
|
|
|
|
for (index, panelId) in createdPanelIds.enumerated() {
|
|
_ = reorderSurface(panelId: panelId, toIndex: index)
|
|
}
|
|
|
|
let selectedPanelId: UUID? = {
|
|
if let selectedOldId = snapshot.selectedPanelId {
|
|
return oldToNewPanelIds[selectedOldId]
|
|
}
|
|
return createdPanelIds.first
|
|
}()
|
|
|
|
if let selectedPanelId,
|
|
let selectedTabId = surfaceIdFromPanelId(selectedPanelId) {
|
|
bonsplitController.focusPane(paneId)
|
|
bonsplitController.selectTab(selectedTabId)
|
|
}
|
|
}
|
|
|
|
private func createPanel(from snapshot: SessionPanelSnapshot, inPane paneId: PaneID) -> UUID? {
|
|
switch snapshot.type {
|
|
case .terminal:
|
|
let workingDirectory = snapshot.terminal?.workingDirectory ?? snapshot.directory ?? currentDirectory
|
|
let replayEnvironment = SessionScrollbackReplayStore.replayEnvironment(
|
|
for: snapshot.terminal?.scrollback
|
|
)
|
|
guard let terminalPanel = newTerminalSurface(
|
|
inPane: paneId,
|
|
focus: false,
|
|
workingDirectory: workingDirectory,
|
|
startupEnvironment: replayEnvironment
|
|
) else {
|
|
return nil
|
|
}
|
|
let fallbackScrollback = SessionPersistencePolicy.truncatedScrollback(snapshot.terminal?.scrollback)
|
|
if let fallbackScrollback {
|
|
restoredTerminalScrollbackByPanelId[terminalPanel.id] = fallbackScrollback
|
|
} else {
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id)
|
|
}
|
|
applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id)
|
|
return terminalPanel.id
|
|
case .browser:
|
|
let initialURL = snapshot.browser?.urlString.flatMap { URL(string: $0) }
|
|
guard let browserPanel = newBrowserSurface(
|
|
inPane: paneId,
|
|
url: initialURL,
|
|
focus: false
|
|
) else {
|
|
return nil
|
|
}
|
|
applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id)
|
|
return browserPanel.id
|
|
case .markdown:
|
|
guard let filePath = snapshot.markdown?.filePath else {
|
|
return nil
|
|
}
|
|
guard let markdownPanel = newMarkdownSurface(
|
|
inPane: paneId,
|
|
filePath: filePath,
|
|
focus: false
|
|
) else {
|
|
return nil
|
|
}
|
|
applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id)
|
|
return markdownPanel.id
|
|
}
|
|
}
|
|
|
|
private func applySessionPanelMetadata(_ snapshot: SessionPanelSnapshot, toPanelId panelId: UUID) {
|
|
if let title = snapshot.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
|
panelTitles[panelId] = title
|
|
}
|
|
|
|
setPanelCustomTitle(panelId: panelId, title: snapshot.customTitle)
|
|
setPanelPinned(panelId: panelId, pinned: snapshot.isPinned)
|
|
|
|
if snapshot.isManuallyUnread {
|
|
markPanelUnread(panelId)
|
|
} else {
|
|
clearManualUnread(panelId: panelId)
|
|
}
|
|
|
|
if let directory = snapshot.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty {
|
|
updatePanelDirectory(panelId: panelId, directory: directory)
|
|
}
|
|
|
|
if let branch = snapshot.gitBranch {
|
|
panelGitBranches[panelId] = SidebarGitBranchState(branch: branch.branch, isDirty: branch.isDirty)
|
|
} else {
|
|
panelGitBranches.removeValue(forKey: panelId)
|
|
}
|
|
|
|
surfaceListeningPorts[panelId] = Array(Set(snapshot.listeningPorts)).sorted()
|
|
|
|
if let ttyName = snapshot.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty {
|
|
surfaceTTYNames[panelId] = ttyName
|
|
} else {
|
|
surfaceTTYNames.removeValue(forKey: panelId)
|
|
}
|
|
|
|
if let browserSnapshot = snapshot.browser,
|
|
let browserPanel = browserPanel(for: panelId) {
|
|
browserPanel.restoreSessionNavigationHistory(
|
|
backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [],
|
|
forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [],
|
|
currentURLString: browserSnapshot.urlString
|
|
)
|
|
|
|
let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom)))
|
|
if pageZoom.isFinite {
|
|
browserPanel.webView.pageZoom = pageZoom
|
|
}
|
|
|
|
if browserSnapshot.developerToolsVisible {
|
|
_ = browserPanel.showDeveloperTools()
|
|
browserPanel.requestDeveloperToolsRefreshAfterNextAttach(reason: "session_restore")
|
|
} else {
|
|
_ = browserPanel.hideDeveloperTools()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applySessionDividerPositions(
|
|
snapshotNode: SessionWorkspaceLayoutSnapshot,
|
|
liveNode: ExternalTreeNode
|
|
) {
|
|
switch (snapshotNode, liveNode) {
|
|
case (.split(let snapshotSplit), .split(let liveSplit)):
|
|
if let splitID = UUID(uuidString: liveSplit.id) {
|
|
_ = bonsplitController.setDividerPosition(
|
|
CGFloat(snapshotSplit.dividerPosition),
|
|
forSplit: splitID,
|
|
fromExternal: true
|
|
)
|
|
}
|
|
applySessionDividerPositions(snapshotNode: snapshotSplit.first, liveNode: liveSplit.first)
|
|
applySessionDividerPositions(snapshotNode: snapshotSplit.second, liveNode: liveSplit.second)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
enum SidebarPullRequestStatus: String {
|
|
case open
|
|
case merged
|
|
case closed
|
|
}
|
|
|
|
struct SidebarPullRequestState: Equatable {
|
|
let number: Int
|
|
let label: String
|
|
let url: URL
|
|
let status: SidebarPullRequestStatus
|
|
}
|
|
|
|
enum SidebarBranchOrdering {
|
|
struct BranchEntry: Equatable {
|
|
let name: String
|
|
let isDirty: Bool
|
|
}
|
|
|
|
struct BranchDirectoryEntry: Equatable {
|
|
let branch: String?
|
|
let isDirty: Bool
|
|
let directory: String?
|
|
}
|
|
|
|
static func orderedPaneIds(tree: ExternalTreeNode) -> [String] {
|
|
switch tree {
|
|
case .pane(let pane):
|
|
return [pane.id]
|
|
case .split(let split):
|
|
// Bonsplit split order matches visual order for both horizontal and vertical splits.
|
|
return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second)
|
|
}
|
|
}
|
|
|
|
static func orderedPanelIds(
|
|
tree: ExternalTreeNode,
|
|
paneTabs: [String: [UUID]],
|
|
fallbackPanelIds: [UUID]
|
|
) -> [UUID] {
|
|
var ordered: [UUID] = []
|
|
var seen: Set<UUID> = []
|
|
|
|
for paneId in orderedPaneIds(tree: tree) {
|
|
for panelId in paneTabs[paneId] ?? [] {
|
|
if seen.insert(panelId).inserted {
|
|
ordered.append(panelId)
|
|
}
|
|
}
|
|
}
|
|
|
|
for panelId in fallbackPanelIds {
|
|
if seen.insert(panelId).inserted {
|
|
ordered.append(panelId)
|
|
}
|
|
}
|
|
|
|
return ordered
|
|
}
|
|
|
|
static func orderedUniqueBranches(
|
|
orderedPanelIds: [UUID],
|
|
panelBranches: [UUID: SidebarGitBranchState],
|
|
fallbackBranch: SidebarGitBranchState?
|
|
) -> [BranchEntry] {
|
|
var orderedNames: [String] = []
|
|
var branchDirty: [String: Bool] = [:]
|
|
|
|
for panelId in orderedPanelIds {
|
|
guard let state = panelBranches[panelId] else { continue }
|
|
let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { continue }
|
|
|
|
if branchDirty[name] == nil {
|
|
orderedNames.append(name)
|
|
branchDirty[name] = state.isDirty
|
|
} else if state.isDirty {
|
|
branchDirty[name] = true
|
|
}
|
|
}
|
|
|
|
if orderedNames.isEmpty, let fallbackBranch {
|
|
let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !name.isEmpty {
|
|
return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)]
|
|
}
|
|
}
|
|
|
|
return orderedNames.map { name in
|
|
BranchEntry(name: name, isDirty: branchDirty[name] ?? false)
|
|
}
|
|
}
|
|
|
|
static func orderedUniquePullRequests(
|
|
orderedPanelIds: [UUID],
|
|
panelPullRequests: [UUID: SidebarPullRequestState],
|
|
fallbackPullRequest: SidebarPullRequestState?
|
|
) -> [SidebarPullRequestState] {
|
|
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
|
|
switch status {
|
|
case .merged: return 3
|
|
case .open: return 2
|
|
case .closed: return 1
|
|
}
|
|
}
|
|
|
|
func normalizedReviewURLKey(for url: URL) -> String {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return url.absoluteString
|
|
}
|
|
|
|
// Treat URL variants that differ only by query/fragment as the same review item.
|
|
components.query = nil
|
|
components.fragment = nil
|
|
let scheme = components.scheme?.lowercased() ?? ""
|
|
let host = components.host?.lowercased() ?? ""
|
|
let port = components.port.map { ":\($0)" } ?? ""
|
|
var path = components.path
|
|
if path.hasSuffix("/"), path.count > 1 {
|
|
path.removeLast()
|
|
}
|
|
return "\(scheme)://\(host)\(port)\(path)"
|
|
}
|
|
|
|
func reviewKey(for state: SidebarPullRequestState) -> String {
|
|
"\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))"
|
|
}
|
|
|
|
var orderedKeys: [String] = []
|
|
var pullRequestsByKey: [String: SidebarPullRequestState] = [:]
|
|
|
|
for panelId in orderedPanelIds {
|
|
guard let state = panelPullRequests[panelId] else { continue }
|
|
let key = reviewKey(for: state)
|
|
if pullRequestsByKey[key] == nil {
|
|
orderedKeys.append(key)
|
|
pullRequestsByKey[key] = state
|
|
continue
|
|
}
|
|
guard let existing = pullRequestsByKey[key] else { continue }
|
|
if statusPriority(state.status) > statusPriority(existing.status) {
|
|
pullRequestsByKey[key] = state
|
|
}
|
|
}
|
|
|
|
if orderedKeys.isEmpty, let fallbackPullRequest {
|
|
return [fallbackPullRequest]
|
|
}
|
|
|
|
return orderedKeys.compactMap { pullRequestsByKey[$0] }
|
|
}
|
|
|
|
static func orderedUniqueBranchDirectoryEntries(
|
|
orderedPanelIds: [UUID],
|
|
panelBranches: [UUID: SidebarGitBranchState],
|
|
panelDirectories: [UUID: String],
|
|
defaultDirectory: String?,
|
|
fallbackBranch: SidebarGitBranchState?
|
|
) -> [BranchDirectoryEntry] {
|
|
struct EntryKey: Hashable {
|
|
let directory: String?
|
|
let branch: String?
|
|
}
|
|
|
|
struct MutableEntry {
|
|
var branch: String?
|
|
var isDirty: Bool
|
|
var directory: String?
|
|
}
|
|
|
|
func normalized(_ text: String?) -> String? {
|
|
guard let text else { return nil }
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
func canonicalDirectoryKey(_ directory: String?) -> String? {
|
|
guard let directory = normalized(directory) else { return nil }
|
|
let expanded = NSString(string: directory).expandingTildeInPath
|
|
let standardized = NSString(string: expanded).standardizingPath
|
|
let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return cleaned.isEmpty ? nil : cleaned
|
|
}
|
|
|
|
let normalizedFallbackBranch = normalized(fallbackBranch?.branch)
|
|
let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains {
|
|
normalized(panelBranches[$0]?.branch) != nil
|
|
}
|
|
let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil
|
|
let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false
|
|
|
|
var order: [EntryKey] = []
|
|
var entries: [EntryKey: MutableEntry] = [:]
|
|
|
|
for panelId in orderedPanelIds {
|
|
let panelBranch = normalized(panelBranches[panelId]?.branch)
|
|
let branch = panelBranch ?? defaultBranchForPanels
|
|
let directory = normalized(panelDirectories[panelId] ?? defaultDirectory)
|
|
guard branch != nil || directory != nil else { continue }
|
|
|
|
let panelDirty = panelBranch != nil
|
|
? (panelBranches[panelId]?.isDirty ?? false)
|
|
: defaultBranchDirty
|
|
|
|
let key: EntryKey
|
|
if let directoryKey = canonicalDirectoryKey(directory) {
|
|
// Keep one line per directory and allow the latest branch state to overwrite.
|
|
key = EntryKey(directory: directoryKey, branch: nil)
|
|
} else {
|
|
key = EntryKey(directory: nil, branch: branch)
|
|
}
|
|
|
|
guard key.directory != nil || key.branch != nil else { continue }
|
|
|
|
if var existing = entries[key] {
|
|
if key.directory != nil {
|
|
if let branch {
|
|
existing.branch = branch
|
|
existing.isDirty = panelDirty
|
|
} else if existing.branch == nil {
|
|
existing.isDirty = panelDirty
|
|
}
|
|
if let directory {
|
|
existing.directory = directory
|
|
}
|
|
entries[key] = existing
|
|
} else if panelDirty {
|
|
existing.isDirty = true
|
|
entries[key] = existing
|
|
}
|
|
} else {
|
|
order.append(key)
|
|
entries[key] = MutableEntry(branch: branch, isDirty: panelDirty, directory: directory)
|
|
}
|
|
}
|
|
|
|
if order.isEmpty {
|
|
let fallbackDirectory = normalized(defaultDirectory)
|
|
if normalizedFallbackBranch != nil || fallbackDirectory != nil {
|
|
return [
|
|
BranchDirectoryEntry(
|
|
branch: normalizedFallbackBranch,
|
|
isDirty: fallbackBranch?.isDirty ?? false,
|
|
directory: fallbackDirectory
|
|
)
|
|
]
|
|
}
|
|
}
|
|
|
|
return order.compactMap { key in
|
|
guard let entry = entries[key] else { return nil }
|
|
return BranchDirectoryEntry(
|
|
branch: entry.branch,
|
|
isDirty: entry.isDirty,
|
|
directory: entry.directory
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ClosedBrowserPanelRestoreSnapshot {
|
|
let workspaceId: UUID
|
|
let url: URL?
|
|
let originalPaneId: UUID
|
|
let originalTabIndex: Int
|
|
let fallbackSplitOrientation: SplitOrientation?
|
|
let fallbackSplitInsertFirst: Bool
|
|
let fallbackAnchorPaneId: UUID?
|
|
}
|
|
|
|
/// 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 customColor: String? // hex string, e.g. "#C0392B"
|
|
@Published var currentDirectory: String
|
|
|
|
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
|
|
var portOrdinal: Int = 0
|
|
|
|
/// 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
|
|
|
|
/// Last terminal panel used as an inheritance source (typically last focused terminal).
|
|
private var lastTerminalConfigInheritancePanelId: UUID?
|
|
/// Last known terminal font points from inheritance sources. Used as fallback when
|
|
/// no live terminal surface is currently available.
|
|
private var lastTerminalConfigInheritanceFontPoints: Float?
|
|
/// Per-panel inherited zoom lineage. Descendants reuse this root value unless
|
|
/// a panel is explicitly re-zoomed by the user.
|
|
private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:]
|
|
|
|
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
|
|
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
|
|
|
|
|
|
// 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
|
|
}
|
|
|
|
enum FocusPanelTrigger {
|
|
case standard
|
|
case terminalFirstResponder
|
|
}
|
|
|
|
/// Published directory for each panel
|
|
@Published var panelDirectories: [UUID: String] = [:]
|
|
@Published var panelTitles: [UUID: String] = [:]
|
|
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
|
|
@Published private(set) var pinnedPanelIds: Set<UUID> = []
|
|
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
|
|
private var manualUnreadMarkedAt: [UUID: Date] = [:]
|
|
nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2
|
|
nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2
|
|
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
|
|
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
|
|
@Published var logEntries: [SidebarLogEntry] = []
|
|
@Published var progress: SidebarProgressState?
|
|
@Published var gitBranch: SidebarGitBranchState?
|
|
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
|
@Published var pullRequest: SidebarPullRequestState?
|
|
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
|
|
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
|
@Published var listeningPorts: [Int] = []
|
|
var surfaceTTYNames: [UUID: String] = [:]
|
|
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
|
|
|
var focusedSurfaceId: UUID? { focusedPanelId }
|
|
var surfaceDirectories: [UUID: String] {
|
|
get { panelDirectories }
|
|
set { panelDirectories = newValue }
|
|
}
|
|
|
|
private var processTitle: String
|
|
|
|
private enum SurfaceKind {
|
|
static let terminal = "terminal"
|
|
static let browser = "browser"
|
|
static let markdown = "markdown"
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
|
BonsplitConfiguration.SplitButtonTooltips(
|
|
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")),
|
|
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")),
|
|
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")),
|
|
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down"))
|
|
)
|
|
}
|
|
|
|
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
|
|
bonsplitAppearance(
|
|
from: config.backgroundColor,
|
|
backgroundOpacity: config.backgroundOpacity
|
|
)
|
|
}
|
|
|
|
static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
|
|
let themedColor = GhosttyBackgroundTheme.color(
|
|
backgroundColor: backgroundColor,
|
|
opacity: backgroundOpacity
|
|
)
|
|
let includeAlpha = themedColor.alphaComponent < 0.999
|
|
return themedColor.hexString(includeAlpha: includeAlpha)
|
|
}
|
|
|
|
nonisolated static func resolvedChromeColors(
|
|
from backgroundColor: NSColor
|
|
) -> BonsplitConfiguration.Appearance.ChromeColors {
|
|
.init(backgroundHex: backgroundColor.hexString())
|
|
}
|
|
|
|
private static func bonsplitAppearance(
|
|
from backgroundColor: NSColor,
|
|
backgroundOpacity: Double
|
|
) -> BonsplitConfiguration.Appearance {
|
|
BonsplitConfiguration.Appearance(
|
|
splitButtonTooltips: Self.currentSplitButtonTooltips(),
|
|
enableAnimations: false,
|
|
chromeColors: .init(
|
|
backgroundHex: Self.bonsplitChromeHex(
|
|
backgroundColor: backgroundColor,
|
|
backgroundOpacity: backgroundOpacity
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
|
|
applyGhosttyChrome(
|
|
backgroundColor: config.backgroundColor,
|
|
backgroundOpacity: config.backgroundOpacity,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") {
|
|
let nextHex = Self.bonsplitChromeHex(
|
|
backgroundColor: backgroundColor,
|
|
backgroundOpacity: backgroundOpacity
|
|
)
|
|
let currentChromeColors = bonsplitController.configuration.appearance.chromeColors
|
|
let isNoOp = currentChromeColors.backgroundHex == nextHex
|
|
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil"
|
|
GhosttyApp.shared.logBackground(
|
|
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)"
|
|
)
|
|
}
|
|
|
|
if isNoOp {
|
|
return
|
|
}
|
|
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
GhosttyApp.shared.logBackground(
|
|
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")"
|
|
)
|
|
}
|
|
}
|
|
|
|
func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") {
|
|
applyGhosttyChrome(
|
|
backgroundColor: backgroundColor,
|
|
backgroundOpacity: backgroundColor.alphaComponent,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
init(
|
|
title: String = "Terminal",
|
|
workingDirectory: String? = nil,
|
|
portOrdinal: Int = 0,
|
|
configTemplate: ghostty_surface_config_s? = nil
|
|
) {
|
|
self.id = UUID()
|
|
self.portOrdinal = portOrdinal
|
|
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
|
|
// and keep split entry instantaneous.
|
|
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
|
|
// runs for socket/CLI workspace creation and can cause visible typing lag.
|
|
let appearance = Self.bonsplitAppearance(
|
|
from: GhosttyApp.shared.defaultBackgroundColor,
|
|
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity
|
|
)
|
|
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)
|
|
bonsplitController.contextMenuShortcuts = Self.buildContextMenuShortcuts()
|
|
|
|
// 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,
|
|
configTemplate: configTemplate,
|
|
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[terminalPanel.id] = terminalPanel
|
|
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
|
|
|
|
// Create initial tab in bonsplit and store the mapping
|
|
var initialTabId: TabID?
|
|
if let tabId = bonsplitController.createTab(
|
|
title: title,
|
|
icon: "terminal.fill",
|
|
kind: SurfaceKind.terminal,
|
|
isDirty: false,
|
|
isPinned: false
|
|
) {
|
|
surfaceIdToPanelId[tabId] = terminalPanel.id
|
|
initialTabId = tabId
|
|
}
|
|
|
|
// Close the default Welcome tab(s)
|
|
for welcomeTabId in welcomeTabIds {
|
|
bonsplitController.closeTab(welcomeTabId)
|
|
}
|
|
|
|
bonsplitController.onExternalTabDrop = { [weak self] request in
|
|
self?.handleExternalTabDrop(request) ?? false
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func refreshSplitButtonTooltips() {
|
|
let tooltips = Self.currentSplitButtonTooltips()
|
|
var configuration = bonsplitController.configuration
|
|
guard configuration.appearance.splitButtonTooltips != tooltips else { return }
|
|
configuration.appearance.splitButtonTooltips = tooltips
|
|
bonsplitController.configuration = configuration
|
|
}
|
|
|
|
// 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] = [:]
|
|
/// Panel IDs that were in a pane when a pane-close operation was approved.
|
|
/// Bonsplit pane-close does not emit per-tab didClose callbacks.
|
|
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
|
|
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
|
|
private var isApplyingTabSelection = false
|
|
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
|
|
private var isReconcilingFocusState = false
|
|
private var focusReconcileScheduled = false
|
|
#if DEBUG
|
|
private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0
|
|
private var debugLastDidMoveTabTimestamp: TimeInterval = 0
|
|
private var debugDidMoveTabEventCount: UInt64 = 0
|
|
#endif
|
|
private var geometryReconcileScheduled = false
|
|
private var geometryReconcileNeedsRerun = false
|
|
private var isNormalizingPinnedTabOrder = false
|
|
private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert?
|
|
private var nonFocusSplitFocusReassertGeneration: UInt64 = 0
|
|
|
|
private struct PendingNonFocusSplitFocusReassert {
|
|
let generation: UInt64
|
|
let preferredPanelId: UUID
|
|
let splitPanelId: UUID
|
|
}
|
|
|
|
struct DetachedSurfaceTransfer {
|
|
let panelId: UUID
|
|
let panel: any Panel
|
|
let title: String
|
|
let icon: String?
|
|
let iconImageData: Data?
|
|
let kind: String?
|
|
let isLoading: Bool
|
|
let isPinned: Bool
|
|
let directory: String?
|
|
let cachedTitle: String?
|
|
let customTitle: String?
|
|
let manuallyUnread: Bool
|
|
}
|
|
|
|
private var detachingTabIds: Set<TabID> = []
|
|
private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:]
|
|
private var activeDetachCloseTransactions: Int = 0
|
|
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
|
|
|
|
#if DEBUG
|
|
private func debugElapsedMs(since start: TimeInterval) -> String {
|
|
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
|
|
return String(format: "%.2f", ms)
|
|
}
|
|
#endif
|
|
|
|
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.removeDuplicates(),
|
|
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 }
|
|
guard let existing = self.bonsplitController.tab(tabId) else { return }
|
|
|
|
let nextTitle = browserPanel.displayTitle
|
|
if self.panelTitles[browserPanel.id] != nextTitle {
|
|
self.panelTitles[browserPanel.id] = nextTitle
|
|
}
|
|
let resolvedTitle = self.resolvedPanelTitle(panelId: browserPanel.id, fallback: nextTitle)
|
|
let titleUpdate: String? = existing.title == resolvedTitle ? nil : resolvedTitle
|
|
let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon)
|
|
let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading
|
|
|
|
guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return }
|
|
self.bonsplitController.updateTab(
|
|
tabId,
|
|
title: titleUpdate,
|
|
iconImageData: faviconUpdate,
|
|
hasCustomTitle: self.panelCustomTitles[browserPanel.id] != nil,
|
|
isLoading: loadingUpdate
|
|
)
|
|
}
|
|
panelSubscriptions[browserPanel.id] = subscription
|
|
}
|
|
|
|
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
|
|
let subscription = markdownPanel.$displayTitle
|
|
.removeDuplicates()
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self, weak markdownPanel] newTitle in
|
|
guard let self = self,
|
|
let markdownPanel = markdownPanel,
|
|
let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return }
|
|
guard let existing = self.bonsplitController.tab(tabId) else { return }
|
|
|
|
if self.panelTitles[markdownPanel.id] != newTitle {
|
|
self.panelTitles[markdownPanel.id] = newTitle
|
|
}
|
|
let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle)
|
|
guard existing.title != resolvedTitle else { return }
|
|
self.bonsplitController.updateTab(
|
|
tabId,
|
|
title: resolvedTitle,
|
|
hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil
|
|
)
|
|
}
|
|
panelSubscriptions[markdownPanel.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
|
|
}
|
|
|
|
func markdownPanel(for panelId: UUID) -> MarkdownPanel? {
|
|
panels[panelId] as? MarkdownPanel
|
|
}
|
|
|
|
private func surfaceKind(for panel: any Panel) -> String {
|
|
switch panel.panelType {
|
|
case .terminal:
|
|
return SurfaceKind.terminal
|
|
case .browser:
|
|
return SurfaceKind.browser
|
|
case .markdown:
|
|
return SurfaceKind.markdown
|
|
}
|
|
}
|
|
|
|
private func resolvedPanelTitle(panelId: UUID, fallback: String) -> String {
|
|
let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let fallbackTitle = trimmedFallback.isEmpty ? "Tab" : trimmedFallback
|
|
if let custom = panelCustomTitles[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!custom.isEmpty {
|
|
return custom
|
|
}
|
|
return fallbackTitle
|
|
}
|
|
|
|
private func syncPinnedStateForTab(_ tabId: TabID, panelId: UUID) {
|
|
let isPinned = pinnedPanelIds.contains(panelId)
|
|
if let panel = panels[panelId] {
|
|
bonsplitController.updateTab(
|
|
tabId,
|
|
kind: .some(surfaceKind(for: panel)),
|
|
isPinned: isPinned
|
|
)
|
|
} else {
|
|
bonsplitController.updateTab(tabId, isPinned: isPinned)
|
|
}
|
|
}
|
|
|
|
private func hasUnreadNotification(panelId: UUID) -> Bool {
|
|
AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false
|
|
}
|
|
|
|
private func syncUnreadBadgeStateForPanel(_ panelId: UUID) {
|
|
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
|
|
let shouldShowUnread = Self.shouldShowUnreadIndicator(
|
|
hasUnreadNotification: hasUnreadNotification(panelId: panelId),
|
|
isManuallyUnread: manualUnreadPanelIds.contains(panelId)
|
|
)
|
|
if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread {
|
|
return
|
|
}
|
|
bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread)
|
|
}
|
|
|
|
private func normalizePinnedTabs(in paneId: PaneID) {
|
|
guard !isNormalizingPinnedTabOrder else { return }
|
|
isNormalizingPinnedTabOrder = true
|
|
defer { isNormalizingPinnedTabOrder = false }
|
|
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
let pinnedTabs = tabs.filter { tab in
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return false }
|
|
return pinnedPanelIds.contains(panelId)
|
|
}
|
|
let unpinnedTabs = tabs.filter { tab in
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return true }
|
|
return !pinnedPanelIds.contains(panelId)
|
|
}
|
|
let desiredOrder = pinnedTabs + unpinnedTabs
|
|
|
|
for (index, desiredTab) in desiredOrder.enumerated() {
|
|
let currentTabs = bonsplitController.tabs(inPane: paneId)
|
|
guard let currentIndex = currentTabs.firstIndex(where: { $0.id == desiredTab.id }) else { continue }
|
|
if currentIndex != index {
|
|
_ = bonsplitController.reorderTab(desiredTab.id, toIndex: index)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func insertionIndexToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> Int {
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count }
|
|
let pinnedCount = tabs.reduce(into: 0) { count, tab in
|
|
if let panelId = panelIdFromSurfaceId(tab.id), pinnedPanelIds.contains(panelId) {
|
|
count += 1
|
|
}
|
|
}
|
|
let rawTarget = min(anchorIndex + 1, tabs.count)
|
|
return max(rawTarget, pinnedCount)
|
|
}
|
|
|
|
func setPanelCustomTitle(panelId: UUID, title: String?) {
|
|
guard panels[panelId] != nil else { return }
|
|
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let previous = panelCustomTitles[panelId]
|
|
if trimmed.isEmpty {
|
|
guard previous != nil else { return }
|
|
panelCustomTitles.removeValue(forKey: panelId)
|
|
} else {
|
|
guard previous != trimmed else { return }
|
|
panelCustomTitles[panelId] = trimmed
|
|
}
|
|
|
|
guard let panel = panels[panelId], let tabId = surfaceIdFromPanelId(panelId) else { return }
|
|
let baseTitle = panelTitles[panelId] ?? panel.displayTitle
|
|
bonsplitController.updateTab(
|
|
tabId,
|
|
title: resolvedPanelTitle(panelId: panelId, fallback: baseTitle),
|
|
hasCustomTitle: panelCustomTitles[panelId] != nil
|
|
)
|
|
}
|
|
|
|
func isPanelPinned(_ panelId: UUID) -> Bool {
|
|
pinnedPanelIds.contains(panelId)
|
|
}
|
|
|
|
func panelKind(panelId: UUID) -> String? {
|
|
guard let panel = panels[panelId] else { return nil }
|
|
return surfaceKind(for: panel)
|
|
}
|
|
|
|
func panelTitle(panelId: UUID) -> String? {
|
|
guard let panel = panels[panelId] else { return nil }
|
|
let fallback = panelTitles[panelId] ?? panel.displayTitle
|
|
return resolvedPanelTitle(panelId: panelId, fallback: fallback)
|
|
}
|
|
|
|
func setPanelPinned(panelId: UUID, pinned: Bool) {
|
|
guard panels[panelId] != nil else { return }
|
|
let wasPinned = pinnedPanelIds.contains(panelId)
|
|
guard wasPinned != pinned else { return }
|
|
if pinned {
|
|
pinnedPanelIds.insert(panelId)
|
|
} else {
|
|
pinnedPanelIds.remove(panelId)
|
|
}
|
|
|
|
guard let tabId = surfaceIdFromPanelId(panelId),
|
|
let paneId = paneId(forPanelId: panelId) else { return }
|
|
bonsplitController.updateTab(tabId, isPinned: pinned)
|
|
normalizePinnedTabs(in: paneId)
|
|
}
|
|
|
|
func markPanelUnread(_ panelId: UUID) {
|
|
guard panels[panelId] != nil else { return }
|
|
guard manualUnreadPanelIds.insert(panelId).inserted else { return }
|
|
manualUnreadMarkedAt[panelId] = Date()
|
|
syncUnreadBadgeStateForPanel(panelId)
|
|
}
|
|
|
|
func markPanelRead(_ panelId: UUID) {
|
|
guard panels[panelId] != nil else { return }
|
|
AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId)
|
|
clearManualUnread(panelId: panelId)
|
|
}
|
|
|
|
func clearManualUnread(panelId: UUID) {
|
|
let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil
|
|
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
|
guard didRemoveUnread else { return }
|
|
syncUnreadBadgeStateForPanel(panelId)
|
|
}
|
|
|
|
static func shouldClearManualUnread(
|
|
previousFocusedPanelId: UUID?,
|
|
nextFocusedPanelId: UUID,
|
|
isManuallyUnread: Bool,
|
|
markedAt: Date?,
|
|
now: Date = Date(),
|
|
sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval
|
|
) -> Bool {
|
|
guard isManuallyUnread else { return false }
|
|
|
|
if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId {
|
|
return true
|
|
}
|
|
|
|
guard let markedAt else { return true }
|
|
return now.timeIntervalSince(markedAt) >= sameTabGraceInterval
|
|
}
|
|
|
|
static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool {
|
|
hasUnreadNotification || isManuallyUnread
|
|
}
|
|
|
|
// 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 setCustomColor(_ hex: String?) {
|
|
if let hex {
|
|
customColor = WorkspaceTabColorSettings.normalizedHex(hex)
|
|
} else {
|
|
customColor = nil
|
|
}
|
|
}
|
|
|
|
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 {
|
|
currentDirectory = trimmed
|
|
}
|
|
}
|
|
|
|
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
|
|
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
|
|
let existing = panelGitBranches[panelId]
|
|
if existing?.branch != branch || existing?.isDirty != isDirty {
|
|
panelGitBranches[panelId] = state
|
|
}
|
|
if panelId == focusedPanelId {
|
|
gitBranch = state
|
|
}
|
|
}
|
|
|
|
func clearPanelGitBranch(panelId: UUID) {
|
|
panelGitBranches.removeValue(forKey: panelId)
|
|
if panelId == focusedPanelId {
|
|
gitBranch = nil
|
|
}
|
|
}
|
|
|
|
func updatePanelPullRequest(
|
|
panelId: UUID,
|
|
number: Int,
|
|
label: String,
|
|
url: URL,
|
|
status: SidebarPullRequestStatus
|
|
) {
|
|
let state = SidebarPullRequestState(number: number, label: label, url: url, status: status)
|
|
let existing = panelPullRequests[panelId]
|
|
if existing != state {
|
|
panelPullRequests[panelId] = state
|
|
}
|
|
if panelId == focusedPanelId {
|
|
pullRequest = state
|
|
}
|
|
}
|
|
|
|
func clearPanelPullRequest(panelId: UUID) {
|
|
panelPullRequests.removeValue(forKey: panelId)
|
|
if panelId == focusedPanelId {
|
|
pullRequest = nil
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
|
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return false }
|
|
var didMutate = false
|
|
|
|
if panelTitles[panelId] != trimmed {
|
|
panelTitles[panelId] = trimmed
|
|
didMutate = true
|
|
}
|
|
|
|
// Update bonsplit tab title only when this panel's title changed.
|
|
if didMutate,
|
|
let tabId = surfaceIdFromPanelId(panelId),
|
|
let panel = panels[panelId] {
|
|
let baseTitle = panelTitles[panelId] ?? panel.displayTitle
|
|
let resolvedTitle = resolvedPanelTitle(panelId: panelId, fallback: baseTitle)
|
|
bonsplitController.updateTab(
|
|
tabId,
|
|
title: resolvedTitle,
|
|
hasCustomTitle: panelCustomTitles[panelId] != nil
|
|
)
|
|
}
|
|
|
|
// If this is the only panel and no custom title, update workspace title
|
|
if panels.count == 1, customTitle == nil {
|
|
if self.title != trimmed {
|
|
self.title = trimmed
|
|
didMutate = true
|
|
}
|
|
if processTitle != trimmed {
|
|
processTitle = trimmed
|
|
}
|
|
}
|
|
|
|
return didMutate
|
|
}
|
|
|
|
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>) {
|
|
panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) }
|
|
panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) }
|
|
panelCustomTitles = panelCustomTitles.filter { validSurfaceIds.contains($0.key) }
|
|
pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) }
|
|
manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) }
|
|
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
|
|
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
|
|
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
|
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
|
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
|
recomputeListeningPorts()
|
|
}
|
|
|
|
func recomputeListeningPorts() {
|
|
let unique = Set(surfaceListeningPorts.values.flatMap { $0 })
|
|
let next = unique.sorted()
|
|
if listeningPorts != next {
|
|
listeningPorts = next
|
|
}
|
|
}
|
|
|
|
func sidebarOrderedPanelIds() -> [UUID] {
|
|
let paneTabs: [String: [UUID]] = Dictionary(
|
|
uniqueKeysWithValues: bonsplitController.allPaneIds.map { paneId in
|
|
let panelIds = bonsplitController
|
|
.tabs(inPane: paneId)
|
|
.compactMap { panelIdFromSurfaceId($0.id) }
|
|
return (paneId.id.uuidString, panelIds)
|
|
}
|
|
)
|
|
|
|
let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString }
|
|
let tree = bonsplitController.treeSnapshot()
|
|
return SidebarBranchOrdering.orderedPanelIds(
|
|
tree: tree,
|
|
paneTabs: paneTabs,
|
|
fallbackPanelIds: fallbackPanelIds
|
|
)
|
|
}
|
|
|
|
func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] {
|
|
SidebarBranchOrdering
|
|
.orderedUniqueBranches(
|
|
orderedPanelIds: orderedPanelIds,
|
|
panelBranches: panelGitBranches,
|
|
fallbackBranch: gitBranch
|
|
)
|
|
.map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) }
|
|
}
|
|
|
|
func sidebarGitBranchesInDisplayOrder() -> [SidebarGitBranchState] {
|
|
sidebarGitBranchesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
|
|
}
|
|
|
|
func sidebarBranchDirectoryEntriesInDisplayOrder(
|
|
orderedPanelIds: [UUID]
|
|
) -> [SidebarBranchOrdering.BranchDirectoryEntry] {
|
|
SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
|
orderedPanelIds: orderedPanelIds,
|
|
panelBranches: panelGitBranches,
|
|
panelDirectories: panelDirectories,
|
|
defaultDirectory: currentDirectory,
|
|
fallbackBranch: gitBranch
|
|
)
|
|
}
|
|
|
|
func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] {
|
|
sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
|
|
}
|
|
|
|
func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] {
|
|
SidebarBranchOrdering.orderedUniquePullRequests(
|
|
orderedPanelIds: orderedPanelIds,
|
|
panelPullRequests: panelPullRequests,
|
|
fallbackPullRequest: pullRequest
|
|
)
|
|
}
|
|
|
|
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
|
|
sidebarPullRequestsInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds())
|
|
}
|
|
|
|
func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] {
|
|
statusEntries.values.sorted { lhs, rhs in
|
|
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
|
|
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
|
return lhs.key < rhs.key
|
|
}
|
|
}
|
|
|
|
func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] {
|
|
metadataBlocks.values.sorted { lhs, rhs in
|
|
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
|
|
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
|
return lhs.key < rhs.key
|
|
}
|
|
}
|
|
|
|
// MARK: - Panel Operations
|
|
|
|
private func seedTerminalInheritanceFontPoints(
|
|
panelId: UUID,
|
|
configTemplate: ghostty_surface_config_s?
|
|
) {
|
|
guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return }
|
|
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
|
|
lastTerminalConfigInheritanceFontPoints = fontPoints
|
|
}
|
|
|
|
private func resolvedTerminalInheritanceFontPoints(
|
|
for terminalPanel: TerminalPanel,
|
|
sourceSurface: ghostty_surface_t,
|
|
inheritedConfig: ghostty_surface_config_s
|
|
) -> Float? {
|
|
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
|
|
if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 {
|
|
if let runtimePoints, abs(runtimePoints - rooted) > 0.05 {
|
|
// Runtime zoom changed after lineage was seeded (manual zoom on descendant);
|
|
// treat runtime as the new root for future descendants.
|
|
return runtimePoints
|
|
}
|
|
return rooted
|
|
}
|
|
if inheritedConfig.font_size > 0 {
|
|
return inheritedConfig.font_size
|
|
}
|
|
return runtimePoints
|
|
}
|
|
|
|
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
|
|
lastTerminalConfigInheritancePanelId = terminalPanel.id
|
|
if let sourceSurface = terminalPanel.surface.surface,
|
|
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
|
|
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
|
|
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
|
|
}
|
|
lastTerminalConfigInheritanceFontPoints =
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
|
|
}
|
|
}
|
|
|
|
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
|
|
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
|
|
return terminalPanel(for: panelId)
|
|
}
|
|
|
|
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
|
|
lastTerminalConfigInheritanceFontPoints
|
|
}
|
|
|
|
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
|
|
/// Preference order:
|
|
/// 1) explicitly preferred terminal panel (when the caller has one),
|
|
/// 2) selected terminal in the target pane,
|
|
/// 3) currently focused terminal in the workspace,
|
|
/// 4) last remembered terminal source,
|
|
/// 5) first terminal tab in the target pane,
|
|
/// 6) deterministic workspace fallback.
|
|
private func terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> [TerminalPanel] {
|
|
var candidates: [TerminalPanel] = []
|
|
var seen: Set<UUID> = []
|
|
|
|
func appendCandidate(_ panel: TerminalPanel?) {
|
|
guard let panel, seen.insert(panel.id).inserted else { return }
|
|
candidates.append(panel)
|
|
}
|
|
|
|
if let preferredPanelId,
|
|
let terminalPanel = terminalPanel(for: preferredPanelId) {
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
|
|
if let preferredPaneId,
|
|
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
|
|
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
|
|
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
|
|
appendCandidate(selectedTerminalPanel)
|
|
}
|
|
|
|
if let focusedTerminalPanel {
|
|
appendCandidate(focusedTerminalPanel)
|
|
}
|
|
|
|
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
|
|
appendCandidate(rememberedTerminalPanel)
|
|
}
|
|
|
|
if let preferredPaneId {
|
|
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let terminalPanel = terminalPanel(for: panelId) else { continue }
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
}
|
|
|
|
for terminalPanel in panels.values
|
|
.compactMap({ $0 as? TerminalPanel })
|
|
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
/// Picks the first terminal panel candidate used as the inheritance source.
|
|
func terminalPanelForConfigInheritance(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> TerminalPanel? {
|
|
terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: preferredPanelId,
|
|
inPane: preferredPaneId
|
|
).first
|
|
}
|
|
|
|
private func inheritedTerminalConfig(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> ghostty_surface_config_s? {
|
|
// Walk candidates in priority order and use the first panel with a live surface.
|
|
// This avoids returning nil when the top candidate exists but is not attached yet.
|
|
for terminalPanel in terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: preferredPanelId,
|
|
inPane: preferredPaneId
|
|
) {
|
|
guard let sourceSurface = terminalPanel.surface.surface else { continue }
|
|
var config = cmuxInheritedSurfaceConfig(
|
|
sourceSurface: sourceSurface,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
|
|
)
|
|
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
|
|
for: terminalPanel,
|
|
sourceSurface: sourceSurface,
|
|
inheritedConfig: config
|
|
), rootedFontPoints > 0 {
|
|
config.font_size = rootedFontPoints
|
|
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
|
|
}
|
|
rememberTerminalConfigInheritanceSource(terminalPanel)
|
|
if config.font_size > 0 {
|
|
lastTerminalConfigInheritanceFontPoints = config.font_size
|
|
}
|
|
return config
|
|
}
|
|
|
|
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
|
|
var config = ghostty_surface_config_new()
|
|
config.font_size = fallbackFontPoints
|
|
#if DEBUG
|
|
dlog(
|
|
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
|
|
)
|
|
#endif
|
|
return config
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Create a new split with a terminal panel
|
|
@discardableResult
|
|
func newTerminalSplit(
|
|
from panelId: UUID,
|
|
orientation: SplitOrientation,
|
|
insertFirst: Bool = false,
|
|
focus: Bool = true
|
|
) -> TerminalPanel? {
|
|
// 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 }
|
|
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
|
|
|
// Create the new terminal panel.
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
|
|
|
// 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,
|
|
kind: SurfaceKind.terminal,
|
|
isDirty: newPanel.isDirty,
|
|
isPinned: false
|
|
)
|
|
surfaceIdToPanelId[newTab.id] = newPanel.id
|
|
let previousFocusedPanelId = focusedPanelId
|
|
|
|
// Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
|
|
// so we can hand it to focusPanel as the "move focus FROM" view.
|
|
let previousHostedView = focusedTerminalPanel?.hostedView
|
|
|
|
// 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)
|
|
panelTitles.removeValue(forKey: newPanel.id)
|
|
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
|
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
|
|
return nil
|
|
}
|
|
|
|
#if DEBUG
|
|
dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
|
|
#endif
|
|
|
|
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
|
|
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
|
|
// stealing focus from the new panel and creating model/surface divergence.
|
|
if focus {
|
|
previousHostedView?.suppressReparentFocus()
|
|
focusPanel(newPanel.id, previousHostedView: previousHostedView)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
previousHostedView?.clearSuppressReparentFocus()
|
|
}
|
|
} else {
|
|
preserveFocusAfterNonFocusSplit(
|
|
preferredPanelId: previousFocusedPanelId,
|
|
splitPanelId: newPanel.id,
|
|
previousHostedView: previousHostedView
|
|
)
|
|
}
|
|
|
|
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,
|
|
workingDirectory: String? = nil,
|
|
startupEnvironment: [String: String] = [:]
|
|
) -> TerminalPanel? {
|
|
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
|
|
|
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
|
|
|
|
// Create new terminal panel
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
workingDirectory: workingDirectory,
|
|
additionalEnvironment: startupEnvironment,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
|
|
|
// Create tab in bonsplit
|
|
guard let newTabId = bonsplitController.createTab(
|
|
title: newPanel.displayTitle,
|
|
icon: newPanel.displayIcon,
|
|
kind: SurfaceKind.terminal,
|
|
isDirty: newPanel.isDirty,
|
|
isPinned: false,
|
|
inPane: paneId
|
|
) else {
|
|
panels.removeValue(forKey: newPanel.id)
|
|
panelTitles.removeValue(forKey: newPanel.id)
|
|
terminalInheritanceFontPointsByPanelId.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 {
|
|
bonsplitController.focusPane(paneId)
|
|
bonsplitController.selectTab(newTabId)
|
|
newPanel.focus()
|
|
applyTabSelection(tabId: newTabId, inPane: paneId)
|
|
}
|
|
return newPanel
|
|
}
|
|
|
|
/// Create a new browser panel split
|
|
@discardableResult
|
|
func newBrowserSplit(
|
|
from panelId: UUID,
|
|
orientation: SplitOrientation,
|
|
insertFirst: Bool = false,
|
|
url: URL? = nil,
|
|
focus: Bool = true
|
|
) -> 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
|
|
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
|
|
|
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
|
|
let newTab = Bonsplit.Tab(
|
|
title: browserPanel.displayTitle,
|
|
icon: browserPanel.displayIcon,
|
|
kind: SurfaceKind.browser,
|
|
isDirty: browserPanel.isDirty,
|
|
isLoading: browserPanel.isLoading,
|
|
isPinned: false
|
|
)
|
|
surfaceIdToPanelId[newTab.id] = browserPanel.id
|
|
let previousFocusedPanelId = focusedPanelId
|
|
|
|
// 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)
|
|
panelTitles.removeValue(forKey: browserPanel.id)
|
|
return nil
|
|
}
|
|
|
|
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
|
|
let previousHostedView = focusedTerminalPanel?.hostedView
|
|
if focus {
|
|
previousHostedView?.suppressReparentFocus()
|
|
focusPanel(browserPanel.id)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
previousHostedView?.clearSuppressReparentFocus()
|
|
}
|
|
} else {
|
|
preserveFocusAfterNonFocusSplit(
|
|
preferredPanelId: previousFocusedPanelId,
|
|
splitPanelId: browserPanel.id,
|
|
previousHostedView: previousHostedView
|
|
)
|
|
}
|
|
|
|
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,
|
|
insertAtEnd: Bool = false,
|
|
bypassInsecureHTTPHostOnce: String? = nil
|
|
) -> BrowserPanel? {
|
|
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
|
|
|
let browserPanel = BrowserPanel(
|
|
workspaceId: id,
|
|
initialURL: url,
|
|
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
|
|
)
|
|
panels[browserPanel.id] = browserPanel
|
|
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
|
|
|
guard let newTabId = bonsplitController.createTab(
|
|
title: browserPanel.displayTitle,
|
|
icon: browserPanel.displayIcon,
|
|
kind: SurfaceKind.browser,
|
|
isDirty: browserPanel.isDirty,
|
|
isLoading: browserPanel.isLoading,
|
|
isPinned: false,
|
|
inPane: paneId
|
|
) else {
|
|
panels.removeValue(forKey: browserPanel.id)
|
|
panelTitles.removeValue(forKey: browserPanel.id)
|
|
return nil
|
|
}
|
|
|
|
surfaceIdToPanelId[newTabId] = browserPanel.id
|
|
|
|
// Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement.
|
|
if insertAtEnd {
|
|
let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1)
|
|
_ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// MARK: - Markdown Panel Creation
|
|
|
|
/// Create a new markdown panel split from an existing panel.
|
|
func newMarkdownSplit(
|
|
from panelId: UUID,
|
|
orientation: SplitOrientation,
|
|
insertFirst: Bool = false,
|
|
filePath: String,
|
|
focus: Bool = true
|
|
) -> MarkdownPanel? {
|
|
// 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 markdown panel
|
|
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
|
|
panels[markdownPanel.id] = markdownPanel
|
|
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
|
|
|
|
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
|
|
let newTab = Bonsplit.Tab(
|
|
title: markdownPanel.displayTitle,
|
|
icon: markdownPanel.displayIcon,
|
|
kind: SurfaceKind.markdown,
|
|
isDirty: markdownPanel.isDirty,
|
|
isLoading: false,
|
|
isPinned: false
|
|
)
|
|
surfaceIdToPanelId[newTab.id] = markdownPanel.id
|
|
let previousFocusedPanelId = focusedPanelId
|
|
|
|
// Create the split with the markdown tab already present in the new pane.
|
|
// 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: markdownPanel.id)
|
|
panelTitles.removeValue(forKey: markdownPanel.id)
|
|
return nil
|
|
}
|
|
|
|
// Suppress old view's becomeFirstResponder during reparenting.
|
|
let previousHostedView = focusedTerminalPanel?.hostedView
|
|
if focus {
|
|
previousHostedView?.suppressReparentFocus()
|
|
focusPanel(markdownPanel.id)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
previousHostedView?.clearSuppressReparentFocus()
|
|
}
|
|
} else {
|
|
preserveFocusAfterNonFocusSplit(
|
|
preferredPanelId: previousFocusedPanelId,
|
|
splitPanelId: markdownPanel.id,
|
|
previousHostedView: previousHostedView
|
|
)
|
|
}
|
|
|
|
installMarkdownPanelSubscription(markdownPanel)
|
|
|
|
return markdownPanel
|
|
}
|
|
|
|
/// Create a new markdown surface (tab) in the specified pane.
|
|
@discardableResult
|
|
func newMarkdownSurface(
|
|
inPane paneId: PaneID,
|
|
filePath: String,
|
|
focus: Bool? = nil
|
|
) -> MarkdownPanel? {
|
|
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
|
|
|
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
|
|
panels[markdownPanel.id] = markdownPanel
|
|
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
|
|
|
|
guard let newTabId = bonsplitController.createTab(
|
|
title: markdownPanel.displayTitle,
|
|
icon: markdownPanel.displayIcon,
|
|
kind: SurfaceKind.markdown,
|
|
isDirty: markdownPanel.isDirty,
|
|
isLoading: false,
|
|
isPinned: false,
|
|
inPane: paneId
|
|
) else {
|
|
panels.removeValue(forKey: markdownPanel.id)
|
|
panelTitles.removeValue(forKey: markdownPanel.id)
|
|
return nil
|
|
}
|
|
|
|
surfaceIdToPanelId[newTabId] = markdownPanel.id
|
|
|
|
// Match terminal behavior: enforce deterministic selection + focus.
|
|
if shouldFocusNewTab {
|
|
bonsplitController.focusPane(paneId)
|
|
bonsplitController.selectTab(newTabId)
|
|
applyTabSelection(tabId: newTabId, inPane: paneId)
|
|
}
|
|
|
|
installMarkdownPanelSubscription(markdownPanel)
|
|
|
|
return markdownPanel
|
|
}
|
|
|
|
/// Tear down all panels in this workspace, freeing their Ghostty surfaces.
|
|
/// Called before the workspace is removed from TabManager to ensure child
|
|
/// processes receive SIGHUP even if ARC deallocation is delayed.
|
|
func teardownAllPanels() {
|
|
let panelEntries = Array(panels)
|
|
for (panelId, panel) in panelEntries {
|
|
panelSubscriptions.removeValue(forKey: panelId)
|
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
|
panel.close()
|
|
}
|
|
|
|
panels.removeAll(keepingCapacity: false)
|
|
surfaceIdToPanelId.removeAll(keepingCapacity: false)
|
|
panelSubscriptions.removeAll(keepingCapacity: false)
|
|
pruneSurfaceMetadata(validSurfaceIds: [])
|
|
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
|
|
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
|
|
lastTerminalConfigInheritancePanelId = nil
|
|
lastTerminalConfigInheritanceFontPoints = nil
|
|
}
|
|
|
|
/// Close a panel.
|
|
/// Returns true when a bonsplit tab close request was issued.
|
|
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
|
|
#if DEBUG
|
|
let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId)
|
|
dlog(
|
|
"surface.close.request panel=\(panelId.uuidString.prefix(5)) " +
|
|
"force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
|
|
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
|
|
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
|
|
)
|
|
#endif
|
|
if let tabId = surfaceIdFromPanelId(panelId) {
|
|
if force {
|
|
forceCloseTabIds.insert(tabId)
|
|
}
|
|
// Close the tab in bonsplit (this triggers delegate callback)
|
|
let closed = bonsplitController.closeTab(tabId)
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " +
|
|
"tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)"
|
|
)
|
|
#endif
|
|
return closed
|
|
}
|
|
|
|
// Mapping can transiently drift during split-tree mutations. If the target panel is
|
|
// currently focused (or is the active terminal first responder), close whichever tab
|
|
// bonsplit marks selected in that focused pane.
|
|
let firstResponderPanelId = cmuxOwningGhosttyView(
|
|
for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder
|
|
)?.terminalSurface?.id
|
|
let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId
|
|
guard targetIsActive,
|
|
let focusedPane = bonsplitController.focusedPaneId,
|
|
let selected = bonsplitController.selectedTab(inPane: focusedPane) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " +
|
|
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
|
|
"firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " +
|
|
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
if force {
|
|
forceCloseTabIds.insert(selected.id)
|
|
}
|
|
let closed = bonsplitController.closeTab(selected.id)
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " +
|
|
"selectedTab=\(String(describing: selected.id).prefix(5)) " +
|
|
"closed=\(closed ? 1 : 0) " +
|
|
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
|
|
)
|
|
#endif
|
|
return closed
|
|
}
|
|
|
|
#if DEBUG
|
|
private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String {
|
|
guard let panel else { return "panelState=missing" }
|
|
if let terminal = panel as? TerminalPanel {
|
|
let hosted = terminal.hostedView
|
|
let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height)
|
|
let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height)
|
|
let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0
|
|
return
|
|
"panelState=terminal panel=\(panelId.uuidString.prefix(5)) " +
|
|
"surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " +
|
|
"inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " +
|
|
"hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
|
|
}
|
|
if let browser = panel as? BrowserPanel {
|
|
let webView = browser.webView
|
|
let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height)
|
|
return
|
|
"panelState=browser panel=\(panelId.uuidString.prefix(5)) " +
|
|
"webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)"
|
|
}
|
|
return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))"
|
|
}
|
|
#endif
|
|
|
|
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
|
|
}
|
|
|
|
/// Returns the top-right pane in the current split tree.
|
|
/// When a workspace is already split, sidebar PR opens should reuse an existing pane
|
|
/// instead of creating additional right splits.
|
|
func topRightBrowserReusePane() -> PaneID? {
|
|
let paneIds = bonsplitController.allPaneIds
|
|
guard paneIds.count > 1 else { return nil }
|
|
|
|
let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) })
|
|
var paneBounds: [String: CGRect] = [:]
|
|
browserCollectNormalizedPaneBounds(
|
|
node: bonsplitController.treeSnapshot(),
|
|
availableRect: CGRect(x: 0, y: 0, width: 1, height: 1),
|
|
into: &paneBounds
|
|
)
|
|
|
|
guard !paneBounds.isEmpty else {
|
|
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
|
|
}
|
|
|
|
let epsilon = 0.000_1
|
|
let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0
|
|
|
|
let sortedCandidates = paneBounds
|
|
.filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon }
|
|
.sorted { lhs, rhs in
|
|
if abs(lhs.value.minY - rhs.value.minY) > epsilon {
|
|
return lhs.value.minY < rhs.value.minY
|
|
}
|
|
if abs(lhs.value.minX - rhs.value.minX) > epsilon {
|
|
return lhs.value.minX > rhs.value.minX
|
|
}
|
|
return lhs.key < rhs.key
|
|
}
|
|
|
|
for candidate in sortedCandidates {
|
|
if let pane = paneById[candidate.key] {
|
|
return pane
|
|
}
|
|
}
|
|
|
|
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private func browserCollectNormalizedPaneBounds(
|
|
node: ExternalTreeNode,
|
|
availableRect: CGRect,
|
|
into output: inout [String: CGRect]
|
|
) {
|
|
switch node {
|
|
case .pane(let paneNode):
|
|
output[paneNode.id] = availableRect
|
|
case .split(let splitNode):
|
|
let divider = min(max(splitNode.dividerPosition, 0), 1)
|
|
let firstRect: CGRect
|
|
let secondRect: CGRect
|
|
|
|
if splitNode.orientation.lowercased() == "vertical" {
|
|
// Stacked split: first = top, second = bottom
|
|
firstRect = CGRect(
|
|
x: availableRect.minX,
|
|
y: availableRect.minY,
|
|
width: availableRect.width,
|
|
height: availableRect.height * divider
|
|
)
|
|
secondRect = CGRect(
|
|
x: availableRect.minX,
|
|
y: availableRect.minY + (availableRect.height * divider),
|
|
width: availableRect.width,
|
|
height: availableRect.height * (1 - divider)
|
|
)
|
|
} else {
|
|
// Side-by-side split: first = left, second = right
|
|
firstRect = CGRect(
|
|
x: availableRect.minX,
|
|
y: availableRect.minY,
|
|
width: availableRect.width * divider,
|
|
height: availableRect.height
|
|
)
|
|
secondRect = CGRect(
|
|
x: availableRect.minX + (availableRect.width * divider),
|
|
y: availableRect.minY,
|
|
width: availableRect.width * (1 - divider),
|
|
height: availableRect.height
|
|
)
|
|
}
|
|
|
|
browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output)
|
|
browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output)
|
|
}
|
|
}
|
|
|
|
private struct BrowserCloseFallbackPlan {
|
|
let orientation: SplitOrientation
|
|
let insertFirst: Bool
|
|
let anchorPaneId: UUID?
|
|
}
|
|
|
|
private func stageClosedBrowserRestoreSnapshotIfNeeded(for tab: Bonsplit.Tab, inPane pane: PaneID) {
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let browserPanel = browserPanel(for: panelId),
|
|
let tabIndex = bonsplitController.tabs(inPane: pane).firstIndex(where: { $0.id == tab.id }) else {
|
|
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tab.id)
|
|
return
|
|
}
|
|
|
|
let fallbackPlan = browserCloseFallbackPlan(
|
|
forPaneId: pane.id.uuidString,
|
|
in: bonsplitController.treeSnapshot()
|
|
)
|
|
let resolvedURL = browserPanel.currentURL
|
|
?? browserPanel.webView.url
|
|
?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:))
|
|
|
|
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
|
|
workspaceId: id,
|
|
url: resolvedURL,
|
|
originalPaneId: pane.id,
|
|
originalTabIndex: tabIndex,
|
|
fallbackSplitOrientation: fallbackPlan?.orientation,
|
|
fallbackSplitInsertFirst: fallbackPlan?.insertFirst ?? false,
|
|
fallbackAnchorPaneId: fallbackPlan?.anchorPaneId
|
|
)
|
|
}
|
|
|
|
private func clearStagedClosedBrowserRestoreSnapshot(for tabId: TabID) {
|
|
pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
|
|
}
|
|
|
|
private func browserCloseFallbackPlan(
|
|
forPaneId targetPaneId: String,
|
|
in node: ExternalTreeNode
|
|
) -> BrowserCloseFallbackPlan? {
|
|
switch node {
|
|
case .pane:
|
|
return nil
|
|
case .split(let splitNode):
|
|
if case .pane(let firstPane) = splitNode.first, firstPane.id == targetPaneId {
|
|
return BrowserCloseFallbackPlan(
|
|
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
|
|
insertFirst: true,
|
|
anchorPaneId: browserNearestPaneId(
|
|
in: splitNode.second,
|
|
targetCenter: browserPaneCenter(firstPane)
|
|
)
|
|
)
|
|
}
|
|
|
|
if case .pane(let secondPane) = splitNode.second, secondPane.id == targetPaneId {
|
|
return BrowserCloseFallbackPlan(
|
|
orientation: splitNode.orientation.lowercased() == "vertical" ? .vertical : .horizontal,
|
|
insertFirst: false,
|
|
anchorPaneId: browserNearestPaneId(
|
|
in: splitNode.first,
|
|
targetCenter: browserPaneCenter(secondPane)
|
|
)
|
|
)
|
|
}
|
|
|
|
if let nested = browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.first) {
|
|
return nested
|
|
}
|
|
return browserCloseFallbackPlan(forPaneId: targetPaneId, in: splitNode.second)
|
|
}
|
|
}
|
|
|
|
private func browserPaneCenter(_ pane: ExternalPaneNode) -> (x: Double, y: Double) {
|
|
(
|
|
x: pane.frame.x + (pane.frame.width * 0.5),
|
|
y: pane.frame.y + (pane.frame.height * 0.5)
|
|
)
|
|
}
|
|
|
|
private func browserNearestPaneId(
|
|
in node: ExternalTreeNode,
|
|
targetCenter: (x: Double, y: Double)?
|
|
) -> UUID? {
|
|
var panes: [ExternalPaneNode] = []
|
|
browserCollectPaneNodes(node: node, into: &panes)
|
|
guard !panes.isEmpty else { return nil }
|
|
|
|
let bestPane: ExternalPaneNode?
|
|
if let targetCenter {
|
|
bestPane = panes.min { lhs, rhs in
|
|
let lhsCenter = browserPaneCenter(lhs)
|
|
let rhsCenter = browserPaneCenter(rhs)
|
|
let lhsDistance = pow(lhsCenter.x - targetCenter.x, 2) + pow(lhsCenter.y - targetCenter.y, 2)
|
|
let rhsDistance = pow(rhsCenter.x - targetCenter.x, 2) + pow(rhsCenter.y - targetCenter.y, 2)
|
|
if lhsDistance != rhsDistance {
|
|
return lhsDistance < rhsDistance
|
|
}
|
|
return lhs.id < rhs.id
|
|
}
|
|
} else {
|
|
bestPane = panes.first
|
|
}
|
|
|
|
guard let bestPane else { return nil }
|
|
return UUID(uuidString: bestPane.id)
|
|
}
|
|
|
|
@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 }
|
|
#if DEBUG
|
|
let detachStart = ProcessInfo.processInfo.systemUptime
|
|
dlog(
|
|
"split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
|
"tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " +
|
|
"pendingDetached=\(pendingDetachedSurfaces.count)"
|
|
)
|
|
#endif
|
|
|
|
detachingTabIds.insert(tabId)
|
|
forceCloseTabIds.insert(tabId)
|
|
activeDetachCloseTransactions += 1
|
|
defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) }
|
|
guard bonsplitController.closeTab(tabId) else {
|
|
detachingTabIds.remove(tabId)
|
|
pendingDetachedSurfaces.removeValue(forKey: tabId)
|
|
forceCloseTabIds.remove(tabId)
|
|
#if DEBUG
|
|
dlog(
|
|
"split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
|
"tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))"
|
|
)
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
let detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
|
|
#if DEBUG
|
|
dlog(
|
|
"split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
|
"tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " +
|
|
"elapsedMs=\(debugElapsedMs(since: detachStart))"
|
|
)
|
|
#endif
|
|
return detached
|
|
}
|
|
|
|
@discardableResult
|
|
func attachDetachedSurface(
|
|
_ detached: DetachedSurfaceTransfer,
|
|
inPane paneId: PaneID,
|
|
atIndex index: Int? = nil,
|
|
focus: Bool = true
|
|
) -> UUID? {
|
|
#if DEBUG
|
|
let attachStart = ProcessInfo.processInfo.systemUptime
|
|
dlog(
|
|
"split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
|
"pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)"
|
|
)
|
|
#endif
|
|
guard bonsplitController.allPaneIds.contains(paneId) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
|
"reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))"
|
|
)
|
|
#endif
|
|
return nil
|
|
}
|
|
guard panels[detached.panelId] == nil else {
|
|
#if DEBUG
|
|
dlog(
|
|
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
|
"reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))"
|
|
)
|
|
#endif
|
|
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
|
|
}
|
|
if let customTitle = detached.customTitle {
|
|
panelCustomTitles[detached.panelId] = customTitle
|
|
}
|
|
if detached.isPinned {
|
|
pinnedPanelIds.insert(detached.panelId)
|
|
} else {
|
|
pinnedPanelIds.remove(detached.panelId)
|
|
}
|
|
if detached.manuallyUnread {
|
|
manualUnreadPanelIds.insert(detached.panelId)
|
|
manualUnreadMarkedAt[detached.panelId] = .distantPast
|
|
} else {
|
|
manualUnreadPanelIds.remove(detached.panelId)
|
|
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
|
|
}
|
|
|
|
guard let newTabId = bonsplitController.createTab(
|
|
title: detached.title,
|
|
hasCustomTitle: detached.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false,
|
|
icon: detached.icon,
|
|
iconImageData: detached.iconImageData,
|
|
kind: detached.kind,
|
|
isDirty: detached.panel.isDirty,
|
|
isLoading: detached.isLoading,
|
|
isPinned: detached.isPinned,
|
|
inPane: paneId
|
|
) else {
|
|
panels.removeValue(forKey: detached.panelId)
|
|
panelDirectories.removeValue(forKey: detached.panelId)
|
|
panelTitles.removeValue(forKey: detached.panelId)
|
|
panelCustomTitles.removeValue(forKey: detached.panelId)
|
|
pinnedPanelIds.remove(detached.panelId)
|
|
manualUnreadPanelIds.remove(detached.panelId)
|
|
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
|
|
panelSubscriptions.removeValue(forKey: detached.panelId)
|
|
#if DEBUG
|
|
dlog(
|
|
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
|
"reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))"
|
|
)
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
surfaceIdToPanelId[newTabId] = detached.panelId
|
|
if let index {
|
|
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
|
|
}
|
|
syncPinnedStateForTab(newTabId, panelId: detached.panelId)
|
|
syncUnreadBadgeStateForPanel(detached.panelId)
|
|
normalizePinnedTabs(in: paneId)
|
|
|
|
if focus {
|
|
bonsplitController.focusPane(paneId)
|
|
bonsplitController.selectTab(newTabId)
|
|
detached.panel.focus()
|
|
applyTabSelection(tabId: newTabId, inPane: paneId)
|
|
} else {
|
|
scheduleFocusReconcile()
|
|
}
|
|
scheduleTerminalGeometryReconcile()
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
|
"tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " +
|
|
"elapsedMs=\(debugElapsedMs(since: attachStart))"
|
|
)
|
|
#endif
|
|
return detached.panelId
|
|
}
|
|
// MARK: - Focus Management
|
|
|
|
private func preserveFocusAfterNonFocusSplit(
|
|
preferredPanelId: UUID?,
|
|
splitPanelId: UUID,
|
|
previousHostedView: GhosttySurfaceScrollView?
|
|
) {
|
|
guard let preferredPanelId, panels[preferredPanelId] != nil else {
|
|
clearNonFocusSplitFocusReassert()
|
|
scheduleFocusReconcile()
|
|
return
|
|
}
|
|
|
|
let generation = beginNonFocusSplitFocusReassert(
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId
|
|
)
|
|
|
|
// Bonsplit splitPane focuses the newly created pane and may emit one delayed
|
|
// didSelect/didFocus callback. Re-assert focus over multiple turns so model
|
|
// focus and AppKit first responder stay aligned with non-focus-intent splits.
|
|
reassertFocusAfterNonFocusSplit(
|
|
generation: generation,
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId,
|
|
previousHostedView: previousHostedView,
|
|
allowPreviousHostedView: true
|
|
)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.reassertFocusAfterNonFocusSplit(
|
|
generation: generation,
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId,
|
|
previousHostedView: previousHostedView,
|
|
allowPreviousHostedView: false
|
|
)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.reassertFocusAfterNonFocusSplit(
|
|
generation: generation,
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId,
|
|
previousHostedView: previousHostedView,
|
|
allowPreviousHostedView: false
|
|
)
|
|
self.scheduleFocusReconcile()
|
|
self.clearNonFocusSplitFocusReassert(generation: generation)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reassertFocusAfterNonFocusSplit(
|
|
generation: UInt64,
|
|
preferredPanelId: UUID,
|
|
splitPanelId: UUID,
|
|
previousHostedView: GhosttySurfaceScrollView?,
|
|
allowPreviousHostedView: Bool
|
|
) {
|
|
guard matchesPendingNonFocusSplitFocusReassert(
|
|
generation: generation,
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId
|
|
) else {
|
|
return
|
|
}
|
|
|
|
guard panels[preferredPanelId] != nil else {
|
|
clearNonFocusSplitFocusReassert(generation: generation)
|
|
return
|
|
}
|
|
|
|
if focusedPanelId == splitPanelId {
|
|
focusPanel(
|
|
preferredPanelId,
|
|
previousHostedView: allowPreviousHostedView ? previousHostedView : nil
|
|
)
|
|
return
|
|
}
|
|
|
|
guard focusedPanelId == preferredPanelId,
|
|
let terminalPanel = terminalPanel(for: preferredPanelId) else {
|
|
return
|
|
}
|
|
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
|
|
}
|
|
|
|
func focusPanel(
|
|
_ panelId: UUID,
|
|
previousHostedView: GhosttySurfaceScrollView? = nil,
|
|
trigger: FocusPanelTrigger = .standard
|
|
) {
|
|
markExplicitFocusIntent(on: panelId)
|
|
#if DEBUG
|
|
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
|
let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard"
|
|
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)")
|
|
FocusLogStore.shared.append(
|
|
"Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)"
|
|
)
|
|
#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).
|
|
// When a caller passes an explicit previousHostedView (e.g. during split creation where
|
|
// bonsplit has already mutated focusedPaneId), prefer it over the derived value.
|
|
let previousTerminalHostedView = previousHostedView ?? 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
|
|
}()
|
|
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
|
|
#if DEBUG
|
|
if shouldSuppressReentrantRefocus {
|
|
dlog(
|
|
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
|
|
"reason=firstResponderAlreadyConverged"
|
|
)
|
|
}
|
|
#endif
|
|
|
|
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) && !shouldSuppressReentrantRefocus {
|
|
panel.focus()
|
|
}
|
|
|
|
if !shouldSuppressReentrantRefocus, 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, !shouldSuppressReentrantRefocus {
|
|
applyTabSelection(tabId: tabId, inPane: targetPaneId)
|
|
}
|
|
|
|
if let browserPanel = panels[panelId] as? BrowserPanel {
|
|
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
|
|
}
|
|
}
|
|
|
|
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
|
|
_ browserPanel: BrowserPanel,
|
|
trigger: FocusPanelTrigger
|
|
) {
|
|
guard trigger == .standard else { return }
|
|
guard !isCommandPaletteVisibleForWorkspaceWindow() else { return }
|
|
guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return }
|
|
guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return }
|
|
|
|
_ = browserPanel.requestAddressBarFocus()
|
|
NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id)
|
|
}
|
|
|
|
private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool {
|
|
guard let app = AppDelegate.shared else {
|
|
return false
|
|
}
|
|
|
|
if let manager = app.tabManagerFor(tabId: id),
|
|
let windowId = app.windowId(for: manager),
|
|
let window = app.mainWindow(for: windowId),
|
|
app.isCommandPaletteVisible(for: window) {
|
|
return true
|
|
}
|
|
|
|
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
|
|
return true
|
|
}
|
|
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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(focus: Bool? = nil) -> TerminalPanel? {
|
|
guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil }
|
|
return newTerminalSurface(inPane: focusedPaneId, focus: focus)
|
|
}
|
|
|
|
@discardableResult
|
|
func clearSplitZoom() -> Bool {
|
|
bonsplitController.clearPaneZoom()
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleSplitZoom(panelId: UUID) -> Bool {
|
|
guard let paneId = paneId(forPanelId: panelId) else { return false }
|
|
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
|
|
focusPanel(panelId)
|
|
return true
|
|
}
|
|
|
|
// MARK: - Context Menu Shortcuts
|
|
|
|
static func buildContextMenuShortcuts() -> [TabContextAction: KeyboardShortcut] {
|
|
var shortcuts: [TabContextAction: KeyboardShortcut] = [:]
|
|
let mappings: [(TabContextAction, KeyboardShortcutSettings.Action)] = [
|
|
(.rename, .renameTab),
|
|
(.toggleZoom, .toggleSplitZoom),
|
|
(.newTerminalToRight, .newSurface),
|
|
]
|
|
for (contextAction, settingsAction) in mappings {
|
|
let stored = KeyboardShortcutSettings.shortcut(for: settingsAction)
|
|
if let key = stored.keyEquivalent {
|
|
shortcuts[contextAction] = KeyboardShortcut(key, modifiers: stored.eventModifiers)
|
|
}
|
|
}
|
|
return shortcuts
|
|
}
|
|
|
|
// MARK: - Flash/Notification Support
|
|
|
|
func triggerFocusFlash(panelId: UUID) {
|
|
panels[panelId]?.triggerFlash()
|
|
}
|
|
|
|
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: - Portal Lifecycle
|
|
|
|
/// Hide all terminal portal views for this workspace.
|
|
/// Called before the workspace is unmounted to prevent portal-hosted terminal
|
|
/// views from covering browser panes in the newly selected workspace.
|
|
func hideAllTerminalPortalViews() {
|
|
for panel in panels.values {
|
|
guard let terminal = panel as? TerminalPanel else { continue }
|
|
terminal.hostedView.setVisibleInUI(false)
|
|
TerminalWindowPortalRegistry.hideHostedView(terminal.hostedView)
|
|
}
|
|
}
|
|
|
|
// MARK: - Utility
|
|
|
|
/// Create a new terminal panel (used when replacing the last panel)
|
|
@discardableResult
|
|
func createReplacementTerminalPanel() -> TerminalPanel {
|
|
let inheritedConfig = inheritedTerminalConfig(
|
|
preferredPanelId: focusedPanelId,
|
|
inPane: bonsplitController.focusedPaneId
|
|
)
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_TAB,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
|
|
|
// Create tab in bonsplit
|
|
if let newTabId = bonsplitController.createTab(
|
|
title: newPanel.displayTitle,
|
|
icon: newPanel.displayIcon,
|
|
kind: SurfaceKind.terminal,
|
|
isDirty: newPanel.isDirty,
|
|
isPinned: false
|
|
) {
|
|
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)
|
|
}
|
|
if let dir = panelDirectories[targetPanelId] {
|
|
currentDirectory = dir
|
|
}
|
|
gitBranch = panelGitBranches[targetPanelId]
|
|
pullRequest = panelPullRequests[targetPanelId]
|
|
}
|
|
|
|
/// Reconcile focus/first-responder convergence.
|
|
/// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first.
|
|
private func scheduleFocusReconcile() {
|
|
#if DEBUG
|
|
if isDetachingCloseTransaction {
|
|
debugFocusReconcileScheduledDuringDetachCount += 1
|
|
}
|
|
#endif
|
|
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 reconcileTerminalGeometryPass() -> Bool {
|
|
var needsFollowUpPass = false
|
|
|
|
// Flush pending AppKit layout first so terminal-host bounds reflect latest split topology.
|
|
for window in NSApp.windows {
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
}
|
|
|
|
for panel in panels.values {
|
|
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
|
let hostedView = terminalPanel.hostedView
|
|
let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1
|
|
let hasSurface = terminalPanel.surface.surface != nil
|
|
let isAttached = hostedView.window != nil && hostedView.superview != nil
|
|
|
|
// Split close/reparent churn can transiently detach a surviving terminal view.
|
|
// Force one SwiftUI representable update so the portal binding reattaches it.
|
|
if !isAttached || !hasUsableBounds || !hasSurface {
|
|
terminalPanel.requestViewReattach()
|
|
needsFollowUpPass = true
|
|
}
|
|
|
|
hostedView.reconcileGeometryNow()
|
|
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
|
|
// layout and view lifecycle changes that free surfaces (#432).
|
|
if terminalPanel.surface.surface != nil {
|
|
terminalPanel.surface.forceRefresh()
|
|
}
|
|
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
|
|
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
needsFollowUpPass = true
|
|
}
|
|
}
|
|
|
|
return needsFollowUpPass
|
|
}
|
|
|
|
private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) {
|
|
guard remainingPasses > 0 else {
|
|
geometryReconcileScheduled = false
|
|
geometryReconcileNeedsRerun = false
|
|
return
|
|
}
|
|
|
|
let needsFollowUpPass = reconcileTerminalGeometryPass()
|
|
let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass
|
|
|
|
if shouldRunAgain, remainingPasses > 1 {
|
|
geometryReconcileNeedsRerun = false
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1)
|
|
}
|
|
return
|
|
}
|
|
|
|
geometryReconcileScheduled = false
|
|
geometryReconcileNeedsRerun = false
|
|
}
|
|
|
|
private func scheduleTerminalGeometryReconcile() {
|
|
guard !geometryReconcileScheduled else {
|
|
geometryReconcileNeedsRerun = true
|
|
return
|
|
}
|
|
geometryReconcileScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.runScheduledTerminalGeometryReconcile(remainingPasses: 4)
|
|
}
|
|
}
|
|
|
|
private func scheduleMovedTerminalRefresh(panelId: UUID) {
|
|
guard terminalPanel(for: panelId) != nil else { return }
|
|
|
|
// Force an NSViewRepresentable update after drag/move reparenting. This keeps
|
|
// portal host binding current when a pane auto-closes during tab moves.
|
|
terminalPanel(for: panelId)?.requestViewReattach()
|
|
|
|
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
|
|
panel.hostedView.reconcileGeometryNow()
|
|
if panel.surface.surface != nil {
|
|
panel.surface.forceRefresh()
|
|
}
|
|
if panel.surface.surface == nil {
|
|
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run once immediately and once on the next turn so rapid split close/reparent
|
|
// sequences still get a post-layout redraw.
|
|
runRefreshPass(0)
|
|
runRefreshPass(0.03)
|
|
}
|
|
|
|
private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) {
|
|
for tabId in tabIds {
|
|
if skipPinned,
|
|
let panelId = panelIdFromSurfaceId(tabId),
|
|
pinnedPanelIds.contains(panelId) {
|
|
continue
|
|
}
|
|
_ = bonsplitController.closeTab(tabId)
|
|
}
|
|
}
|
|
|
|
private func tabIdsToLeft(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return [] }
|
|
return Array(tabs.prefix(index).map(\.id))
|
|
}
|
|
|
|
private func tabIdsToRight(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }),
|
|
index + 1 < tabs.count else { return [] }
|
|
return Array(tabs.suffix(from: index + 1).map(\.id))
|
|
}
|
|
|
|
private func tabIdsToCloseOthers(of anchorTabId: TabID, inPane paneId: PaneID) -> [TabID] {
|
|
bonsplitController.tabs(inPane: paneId)
|
|
.map(\.id)
|
|
.filter { $0 != anchorTabId }
|
|
}
|
|
|
|
private func createTerminalToRight(of anchorTabId: TabID, inPane paneId: PaneID) {
|
|
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
|
guard let newPanel = newTerminalSurface(inPane: paneId, focus: true) else { return }
|
|
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
|
}
|
|
|
|
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
|
|
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
|
guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return }
|
|
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
|
}
|
|
|
|
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
|
|
guard let panelId = panelIdFromSurfaceId(anchorTabId),
|
|
let browser = browserPanel(for: panelId) else { return }
|
|
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
|
|
}
|
|
|
|
private func promptRenamePanel(tabId: TabID) {
|
|
guard let panelId = panelIdFromSurfaceId(tabId),
|
|
let panel = panels[panelId] else { return }
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab")
|
|
alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.")
|
|
let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle
|
|
let input = NSTextField(string: currentTitle)
|
|
input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name")
|
|
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
|
|
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
|
let alertWindow = alert.window
|
|
alertWindow.initialFirstResponder = input
|
|
DispatchQueue.main.async {
|
|
alertWindow.makeFirstResponder(input)
|
|
input.selectText(nil)
|
|
}
|
|
let response = alert.runModal()
|
|
guard response == .alertFirstButtonReturn else { return }
|
|
setPanelCustomTitle(panelId: panelId, title: input.stringValue)
|
|
}
|
|
|
|
private enum PanelMoveDestination {
|
|
case newWorkspaceInCurrentWindow
|
|
case selectedWorkspaceInNewWindow
|
|
case existingWorkspace(UUID)
|
|
}
|
|
|
|
private func promptMovePanel(tabId: TabID) {
|
|
guard let panelId = panelIdFromSurfaceId(tabId),
|
|
let app = AppDelegate.shared else { return }
|
|
|
|
let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) }
|
|
let workspaceTargets = app.workspaceMoveTargets(
|
|
excludingWorkspaceId: id,
|
|
referenceWindowId: currentWindowId
|
|
)
|
|
|
|
var options: [(title: String, destination: PanelMoveDestination)] = [
|
|
(String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow),
|
|
(String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow),
|
|
]
|
|
options.append(contentsOf: workspaceTargets.map { target in
|
|
(target.label, .existingWorkspace(target.workspaceId))
|
|
})
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab")
|
|
alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.")
|
|
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
|
|
for option in options {
|
|
popup.addItem(withTitle: option.title)
|
|
}
|
|
popup.selectItem(at: 0)
|
|
alert.accessoryView = popup
|
|
alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move"))
|
|
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
|
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1))
|
|
let destination = options[selectedIndex].destination
|
|
|
|
let moved: Bool
|
|
switch destination {
|
|
case .newWorkspaceInCurrentWindow:
|
|
guard let manager = app.tabManagerFor(tabId: id) else { return }
|
|
let workspace = manager.addWorkspace(select: true)
|
|
moved = app.moveSurface(
|
|
panelId: panelId,
|
|
toWorkspace: workspace.id,
|
|
focus: true,
|
|
focusWindow: false
|
|
)
|
|
|
|
case .selectedWorkspaceInNewWindow:
|
|
let newWindowId = app.createMainWindow()
|
|
guard let destinationManager = app.tabManagerFor(windowId: newWindowId),
|
|
let destinationWorkspaceId = destinationManager.selectedTabId else {
|
|
return
|
|
}
|
|
moved = app.moveSurface(
|
|
panelId: panelId,
|
|
toWorkspace: destinationWorkspaceId,
|
|
focus: true,
|
|
focusWindow: true
|
|
)
|
|
if !moved {
|
|
_ = app.closeMainWindow(windowId: newWindowId)
|
|
}
|
|
|
|
case .existingWorkspace(let workspaceId):
|
|
moved = app.moveSurface(
|
|
panelId: panelId,
|
|
toWorkspace: workspaceId,
|
|
focus: true,
|
|
focusWindow: true
|
|
)
|
|
}
|
|
|
|
if !moved {
|
|
let failure = NSAlert()
|
|
failure.alertStyle = .warning
|
|
failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed")
|
|
failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.")
|
|
failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
|
|
_ = failure.runModal()
|
|
}
|
|
}
|
|
|
|
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
|
|
guard let app = AppDelegate.shared else { return false }
|
|
#if DEBUG
|
|
let dropStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
|
|
let targetPane: PaneID
|
|
let targetIndex: Int?
|
|
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
|
|
#if DEBUG
|
|
let destinationLabel: String
|
|
#endif
|
|
|
|
switch request.destination {
|
|
case .insert(let paneId, let index):
|
|
targetPane = paneId
|
|
targetIndex = index
|
|
splitTarget = nil
|
|
#if DEBUG
|
|
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
|
|
#endif
|
|
case .split(let paneId, let orientation, let insertFirst):
|
|
targetPane = paneId
|
|
targetIndex = nil
|
|
splitTarget = (orientation, insertFirst)
|
|
#if DEBUG
|
|
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
|
|
#endif
|
|
}
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
|
|
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
|
|
)
|
|
#endif
|
|
let moved = app.moveBonsplitTab(
|
|
tabId: request.tabId.uuid,
|
|
toWorkspace: id,
|
|
targetPane: targetPane,
|
|
targetIndex: targetIndex,
|
|
splitTarget: splitTarget,
|
|
focus: true,
|
|
focusWindow: true
|
|
)
|
|
#if DEBUG
|
|
dlog(
|
|
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
|
|
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
|
|
)
|
|
#endif
|
|
return moved
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - BonsplitDelegate
|
|
|
|
extension Workspace: BonsplitDelegate {
|
|
@MainActor
|
|
private func confirmClosePanel(for tabId: TabID) async -> Bool {
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
|
|
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
|
|
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "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) {
|
|
let previousFocusedPanelId = focusedPanelId
|
|
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
|
|
}
|
|
|
|
if shouldTreatCurrentEventAsExplicitFocusIntent() {
|
|
markExplicitFocusIntent(on: panelId)
|
|
}
|
|
|
|
syncPinnedStateForTab(selectedTabId, panelId: panelId)
|
|
syncUnreadBadgeStateForPanel(panelId)
|
|
|
|
// Unfocus all other panels
|
|
for (id, p) in panels where id != panelId {
|
|
p.unfocus()
|
|
}
|
|
|
|
panel.focus()
|
|
let focusIntentAllowsBrowserOmnibarAutofocus =
|
|
shouldTreatCurrentEventAsExplicitFocusIntent() ||
|
|
TerminalController.socketCommandAllowsInAppFocusMutations()
|
|
if let browserPanel = panel as? BrowserPanel,
|
|
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
|
|
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
|
|
}
|
|
if let terminalPanel = panel as? TerminalPanel {
|
|
rememberTerminalConfigInheritanceSource(terminalPanel)
|
|
}
|
|
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
|
|
let markedAt = manualUnreadMarkedAt[panelId]
|
|
if Self.shouldClearManualUnread(
|
|
previousFocusedPanelId: previousFocusedPanelId,
|
|
nextFocusedPanelId: panelId,
|
|
isManuallyUnread: isManuallyUnread,
|
|
markedAt: markedAt
|
|
) {
|
|
triggerFocusFlash(panelId: panelId)
|
|
let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash
|
|
if clearDelay <= 0 {
|
|
clearManualUnread(panelId: panelId)
|
|
} else {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in
|
|
self?.clearManualUnread(panelId: panelId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
gitBranch = panelGitBranches[panelId]
|
|
pullRequest = panelPullRequests[panelId]
|
|
|
|
// Post notification
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusSurface,
|
|
object: nil,
|
|
userInfo: [
|
|
GhosttyNotificationKey.tabId: self.id,
|
|
GhosttyNotificationKey.surfaceId: panelId
|
|
]
|
|
)
|
|
}
|
|
|
|
private func beginNonFocusSplitFocusReassert(
|
|
preferredPanelId: UUID,
|
|
splitPanelId: UUID
|
|
) -> UInt64 {
|
|
nonFocusSplitFocusReassertGeneration &+= 1
|
|
let generation = nonFocusSplitFocusReassertGeneration
|
|
pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert(
|
|
generation: generation,
|
|
preferredPanelId: preferredPanelId,
|
|
splitPanelId: splitPanelId
|
|
)
|
|
return generation
|
|
}
|
|
|
|
private func matchesPendingNonFocusSplitFocusReassert(
|
|
generation: UInt64,
|
|
preferredPanelId: UUID,
|
|
splitPanelId: UUID
|
|
) -> Bool {
|
|
guard let pending = pendingNonFocusSplitFocusReassert else { return false }
|
|
return pending.generation == generation &&
|
|
pending.preferredPanelId == preferredPanelId &&
|
|
pending.splitPanelId == splitPanelId
|
|
}
|
|
|
|
private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) {
|
|
guard let pending = pendingNonFocusSplitFocusReassert else { return }
|
|
if let generation, pending.generation != generation { return }
|
|
pendingNonFocusSplitFocusReassert = nil
|
|
}
|
|
|
|
private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool {
|
|
guard let eventType = NSApp.currentEvent?.type else { return false }
|
|
switch eventType {
|
|
case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
|
|
.otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel,
|
|
.gesture, .magnify, .rotate, .swipe:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func markExplicitFocusIntent(on panelId: UUID) {
|
|
guard let pending = pendingNonFocusSplitFocusReassert,
|
|
pending.splitPanelId == panelId else {
|
|
return
|
|
}
|
|
pendingNonFocusSplitFocusReassert = nil
|
|
}
|
|
|
|
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) {
|
|
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
|
|
recordPostCloseSelection()
|
|
return true
|
|
}
|
|
|
|
if let panelId = panelIdFromSurfaceId(tab.id),
|
|
pinnedPanelIds.contains(panelId) {
|
|
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
|
NSSound.beep()
|
|
return false
|
|
}
|
|
|
|
// Check if the panel needs close confirmation
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let terminalPanel = terminalPanel(for: panelId) else {
|
|
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
|
|
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() {
|
|
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
|
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
|
|
}
|
|
|
|
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
|
|
recordPostCloseSelection()
|
|
return true
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didCloseTab tabId: TabID, fromPane pane: PaneID) {
|
|
forceCloseTabIds.remove(tabId)
|
|
let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId)
|
|
let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId)
|
|
let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction
|
|
|
|
// Clean up our panel
|
|
guard let panelId = panelIdFromSurfaceId(tabId) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " +
|
|
"pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " +
|
|
"panels=\(panels.count) panes=\(controller.allPaneIds.count)"
|
|
)
|
|
#endif
|
|
scheduleTerminalGeometryReconcile()
|
|
if !isDetaching {
|
|
scheduleFocusReconcile()
|
|
}
|
|
return
|
|
}
|
|
|
|
let panel = panels[panelId]
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " +
|
|
"pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
|
"isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
|
|
"\(debugPanelLifecycleState(panelId: panelId, panel: panel))"
|
|
)
|
|
#endif
|
|
|
|
if isDetaching, let panel {
|
|
let browserPanel = panel as? BrowserPanel
|
|
let cachedTitle = panelTitles[panelId]
|
|
let transferFallbackTitle = cachedTitle ?? panel.displayTitle
|
|
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
|
|
panelId: panelId,
|
|
panel: panel,
|
|
title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle),
|
|
icon: panel.displayIcon,
|
|
iconImageData: browserPanel?.faviconPNGData,
|
|
kind: surfaceKind(for: panel),
|
|
isLoading: browserPanel?.isLoading ?? false,
|
|
isPinned: pinnedPanelIds.contains(panelId),
|
|
directory: panelDirectories[panelId],
|
|
cachedTitle: cachedTitle,
|
|
customTitle: panelCustomTitles[panelId],
|
|
manuallyUnread: manualUnreadPanelIds.contains(panelId)
|
|
)
|
|
} else {
|
|
if let closedBrowserRestoreSnapshot {
|
|
onClosedBrowserPanel?(closedBrowserRestoreSnapshot)
|
|
}
|
|
panel?.close()
|
|
}
|
|
|
|
panels.removeValue(forKey: panelId)
|
|
surfaceIdToPanelId.removeValue(forKey: tabId)
|
|
panelDirectories.removeValue(forKey: panelId)
|
|
panelGitBranches.removeValue(forKey: panelId)
|
|
panelPullRequests.removeValue(forKey: panelId)
|
|
panelTitles.removeValue(forKey: panelId)
|
|
panelCustomTitles.removeValue(forKey: panelId)
|
|
pinnedPanelIds.remove(panelId)
|
|
manualUnreadPanelIds.remove(panelId)
|
|
manualUnreadMarkedAt.removeValue(forKey: panelId)
|
|
panelSubscriptions.removeValue(forKey: panelId)
|
|
surfaceTTYNames.removeValue(forKey: panelId)
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
|
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
|
|
if lastTerminalConfigInheritancePanelId == panelId {
|
|
lastTerminalConfigInheritancePanelId = nil
|
|
}
|
|
|
|
// Keep the workspace invariant for normal close paths.
|
|
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
|
|
// prune the source workspace/window after the tab is attached elsewhere.
|
|
if panels.isEmpty {
|
|
if isDetaching {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace"
|
|
)
|
|
#endif
|
|
scheduleTerminalGeometryReconcile()
|
|
return
|
|
}
|
|
|
|
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()
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " +
|
|
"replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)"
|
|
)
|
|
#endif
|
|
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)
|
|
} else if let focusedPane = bonsplitController.focusedPaneId,
|
|
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
|
|
// When closing the last tab in a pane, Bonsplit may focus a different pane and skip
|
|
// emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync.
|
|
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
|
|
}
|
|
|
|
if bonsplitController.allPaneIds.contains(pane) {
|
|
normalizePinnedTabs(in: pane)
|
|
}
|
|
#if DEBUG
|
|
let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
|
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
|
|
dlog(
|
|
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " +
|
|
"focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)"
|
|
)
|
|
#endif
|
|
scheduleTerminalGeometryReconcile()
|
|
if !isDetaching {
|
|
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) {
|
|
#if DEBUG
|
|
let now = ProcessInfo.processInfo.systemUptime
|
|
let sincePrev: String
|
|
if debugLastDidMoveTabTimestamp > 0 {
|
|
sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000)
|
|
} else {
|
|
sincePrev = "first"
|
|
}
|
|
debugLastDidMoveTabTimestamp = now
|
|
debugDidMoveTabEventCount += 1
|
|
let movedPanelId = panelIdFromSurfaceId(tab.id)
|
|
let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown"
|
|
let selectedBefore = controller.selectedTab(inPane: destination)
|
|
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
|
|
let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
|
let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil"
|
|
dlog(
|
|
"split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " +
|
|
"from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " +
|
|
"sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)"
|
|
)
|
|
dlog(
|
|
"split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
|
|
"destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)"
|
|
)
|
|
#endif
|
|
applyTabSelection(tabId: tab.id, inPane: destination)
|
|
#if DEBUG
|
|
let movedPanelIdAfter = panelIdFromSurfaceId(tab.id)
|
|
#endif
|
|
if let movedPanelId = panelIdFromSurfaceId(tab.id) {
|
|
scheduleMovedTerminalRefresh(panelId: movedPanelId)
|
|
}
|
|
#if DEBUG
|
|
let selectedAfter = controller.selectedTab(inPane: destination)
|
|
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
|
|
let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
|
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
|
|
let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0
|
|
dlog(
|
|
"split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
|
|
"destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " +
|
|
"movedFocused=\(movedPanelFocused)"
|
|
)
|
|
#endif
|
|
normalizePinnedTabs(in: source)
|
|
normalizePinnedTabs(in: destination)
|
|
scheduleTerminalGeometryReconcile()
|
|
if !isDetachingCloseTransaction {
|
|
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) {
|
|
let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? []
|
|
let shouldScheduleFocusReconcile = !isDetachingCloseTransaction
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
if !closedPanelIds.isEmpty {
|
|
for panelId in closedPanelIds {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
|
|
)
|
|
#endif
|
|
panels[panelId]?.close()
|
|
panels.removeValue(forKey: panelId)
|
|
panelDirectories.removeValue(forKey: panelId)
|
|
panelGitBranches.removeValue(forKey: panelId)
|
|
panelPullRequests.removeValue(forKey: panelId)
|
|
panelTitles.removeValue(forKey: panelId)
|
|
panelCustomTitles.removeValue(forKey: panelId)
|
|
pinnedPanelIds.remove(panelId)
|
|
manualUnreadPanelIds.remove(panelId)
|
|
panelSubscriptions.removeValue(forKey: panelId)
|
|
surfaceTTYNames.removeValue(forKey: panelId)
|
|
surfaceListeningPorts.removeValue(forKey: panelId)
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
|
}
|
|
|
|
let closedSet = Set(closedPanelIds)
|
|
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
|
|
recomputeListeningPorts()
|
|
|
|
if let focusedPane = bonsplitController.focusedPaneId,
|
|
let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id {
|
|
applyTabSelection(tabId: focusedTabId, inPane: focusedPane)
|
|
} else if shouldScheduleFocusReconcile {
|
|
scheduleFocusReconcile()
|
|
}
|
|
}
|
|
|
|
scheduleTerminalGeometryReconcile()
|
|
if shouldScheduleFocusReconcile {
|
|
scheduleFocusReconcile()
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
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() {
|
|
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
|
|
return false
|
|
}
|
|
}
|
|
pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) }
|
|
return true
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {
|
|
#if DEBUG
|
|
let panelKindForTab: (TabID) -> String = { tabId in
|
|
guard let panelId = self.panelIdFromSurfaceId(tabId),
|
|
let panel = self.panels[panelId] else { return "placeholder" }
|
|
if panel is TerminalPanel { return "terminal" }
|
|
if panel is BrowserPanel { return "browser" }
|
|
return String(describing: type(of: panel))
|
|
}
|
|
let paneKindSummary: (PaneID) -> String = { paneId in
|
|
let tabs = controller.tabs(inPane: paneId)
|
|
guard !tabs.isEmpty else { return "-" }
|
|
return tabs.map { tab in
|
|
String(panelKindForTab(tab.id).prefix(1))
|
|
}.joined(separator: ",")
|
|
}
|
|
let originalSelectedKind = controller.selectedTab(inPane: originalPane).map { panelKindForTab($0.id) } ?? "none"
|
|
let newSelectedKind = controller.selectedTab(inPane: newPane).map { panelKindForTab($0.id) } ?? "none"
|
|
dlog(
|
|
"split.didSplit original=\(originalPane.id.uuidString.prefix(5)) new=\(newPane.id.uuidString.prefix(5)) " +
|
|
"orientation=\(orientation) programmatic=\(isProgrammaticSplit ? 1 : 0) " +
|
|
"originalTabs=\(controller.tabs(inPane: originalPane).count) newTabs=\(controller.tabs(inPane: newPane).count) " +
|
|
"originalSelected=\(originalSelectedKind) newSelected=\(newSelectedKind) " +
|
|
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
|
|
)
|
|
#endif
|
|
// 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 {
|
|
normalizePinnedTabs(in: originalPane)
|
|
normalizePinnedTabs(in: newPane)
|
|
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 DEBUG
|
|
dlog(
|
|
"split.didSplit.drag original=\(originalPane.id.uuidString.prefix(5)) " +
|
|
"new=\(newPane.id.uuidString.prefix(5)) originalTabs=\(originalTabs.count) " +
|
|
"newTabs=\(controller.tabs(inPane: newPane).count) hasRealSurface=\(hasRealSurface ? 1 : 0) " +
|
|
"originalKinds=[\(paneKindSummary(originalPane))] newKinds=[\(paneKindSummary(newPane))]"
|
|
)
|
|
#endif
|
|
if !hasRealSurface {
|
|
let placeholderTabs = originalTabs.filter { panelIdFromSurfaceId($0.id) == nil }
|
|
#if DEBUG
|
|
dlog(
|
|
"split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " +
|
|
"action=reusePlaceholder placeholderCount=\(placeholderTabs.count)"
|
|
)
|
|
#endif
|
|
if let replacementTab = placeholderTabs.first {
|
|
// Keep the existing placeholder tab identity and replace only the panel mapping.
|
|
// This avoids an extra create+close tab churn that can transiently render an
|
|
// empty pane during drag-to-split of a single-tab pane.
|
|
let inheritedConfig = inheritedTerminalConfig(inPane: originalPane)
|
|
|
|
let replacementPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[replacementPanel.id] = replacementPanel
|
|
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
|
|
surfaceIdToPanelId[replacementTab.id] = replacementPanel.id
|
|
|
|
bonsplitController.updateTab(
|
|
replacementTab.id,
|
|
title: replacementPanel.displayTitle,
|
|
icon: .some(replacementPanel.displayIcon),
|
|
iconImageData: .some(nil),
|
|
kind: .some(SurfaceKind.terminal),
|
|
hasCustomTitle: false,
|
|
isDirty: replacementPanel.isDirty,
|
|
showsNotificationBadge: false,
|
|
isLoading: false,
|
|
isPinned: false
|
|
)
|
|
|
|
for extraPlaceholder in placeholderTabs.dropFirst() {
|
|
bonsplitController.closeTab(extraPlaceholder.id)
|
|
}
|
|
} else {
|
|
#if DEBUG
|
|
dlog(
|
|
"split.placeholderRepair pane=\(originalPane.id.uuidString.prefix(5)) " +
|
|
"fallback=createTerminalAndDropPlaceholders"
|
|
)
|
|
#endif
|
|
_ = newTerminalSurface(inPane: originalPane, focus: false)
|
|
for tab in controller.tabs(inPane: originalPane) {
|
|
if panelIdFromSurfaceId(tab.id) == nil {
|
|
bonsplitController.closeTab(tab.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
normalizePinnedTabs(in: originalPane)
|
|
normalizePinnedTabs(in: newPane)
|
|
scheduleTerminalGeometryReconcile()
|
|
return
|
|
}
|
|
|
|
// Mirror Cmd+D behavior: split buttons should always seed a terminal in the new pane.
|
|
// When the focused source is a browser, inherit terminal config from nearby terminals
|
|
// (or fall back to defaults) instead of leaving an empty selector pane.
|
|
let sourceTabId = controller.selectedTab(inPane: originalPane)?.id
|
|
let sourcePanelId = sourceTabId.flatMap { panelIdFromSurfaceId($0) }
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " +
|
|
"fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.map { String($0.uuidString.prefix(5)) } ?? "none")"
|
|
)
|
|
#endif
|
|
|
|
let inheritedConfig = inheritedTerminalConfig(
|
|
preferredPanelId: sourcePanelId,
|
|
inPane: originalPane
|
|
)
|
|
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
|
|
|
guard let newTabId = bonsplitController.createTab(
|
|
title: newPanel.displayTitle,
|
|
icon: newPanel.displayIcon,
|
|
kind: SurfaceKind.terminal,
|
|
isDirty: newPanel.isDirty,
|
|
isPinned: false,
|
|
inPane: newPane
|
|
) else {
|
|
panels.removeValue(forKey: newPanel.id)
|
|
panelTitles.removeValue(forKey: newPanel.id)
|
|
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
|
|
return
|
|
}
|
|
|
|
surfaceIdToPanelId[newTabId] = newPanel.id
|
|
normalizePinnedTabs(in: newPane)
|
|
#if DEBUG
|
|
dlog(
|
|
"split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " +
|
|
"panel=\(newPanel.id.uuidString.prefix(5))"
|
|
)
|
|
#endif
|
|
|
|
// `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, didRequestNewTab kind: String, inPane pane: PaneID) {
|
|
switch kind {
|
|
case "terminal":
|
|
_ = newTerminalSurface(inPane: pane)
|
|
case "browser":
|
|
_ = newBrowserSurface(inPane: pane)
|
|
default:
|
|
_ = newTerminalSurface(inPane: pane)
|
|
}
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didRequestTabContextAction action: TabContextAction, for tab: Bonsplit.Tab, inPane pane: PaneID) {
|
|
switch action {
|
|
case .rename:
|
|
promptRenamePanel(tabId: tab.id)
|
|
case .clearName:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
setPanelCustomTitle(panelId: panelId, title: nil)
|
|
case .closeToLeft:
|
|
closeTabs(tabIdsToLeft(of: tab.id, inPane: pane))
|
|
case .closeToRight:
|
|
closeTabs(tabIdsToRight(of: tab.id, inPane: pane))
|
|
case .closeOthers:
|
|
closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane))
|
|
case .move:
|
|
promptMovePanel(tabId: tab.id)
|
|
case .newTerminalToRight:
|
|
createTerminalToRight(of: tab.id, inPane: pane)
|
|
case .newBrowserToRight:
|
|
createBrowserToRight(of: tab.id, inPane: pane)
|
|
case .reload:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id),
|
|
let browser = browserPanel(for: panelId) else { return }
|
|
browser.reload()
|
|
case .duplicate:
|
|
duplicateBrowserToRight(anchorTabId: tab.id, inPane: pane)
|
|
case .togglePin:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
let shouldPin = !pinnedPanelIds.contains(panelId)
|
|
setPanelPinned(panelId: panelId, pinned: shouldPin)
|
|
case .markAsRead:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
clearManualUnread(panelId: panelId)
|
|
case .markAsUnread:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
markPanelUnread(panelId)
|
|
case .toggleZoom:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
toggleSplitZoom(panelId: panelId)
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {
|
|
_ = snapshot
|
|
scheduleTerminalGeometryReconcile()
|
|
if !isDetachingCloseTransaction {
|
|
scheduleFocusReconcile()
|
|
}
|
|
}
|
|
|
|
// No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.
|
|
}
|