# Conflicts: # CLI/cmux.swift # Sources/ContentView.swift # Sources/TerminalController.swift # Sources/Workspace.swift
4906 lines
192 KiB
Swift
4906 lines
192 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Bonsplit
|
|
import Combine
|
|
import Darwin
|
|
|
|
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
|
|
}
|
|
|
|
private final class WorkspaceRemoteSessionController {
|
|
private struct ForwardEntry {
|
|
let process: Process
|
|
let stderrPipe: Pipe
|
|
}
|
|
|
|
private struct CommandResult {
|
|
let status: Int32
|
|
let stdout: String
|
|
let stderr: String
|
|
}
|
|
|
|
private struct RemotePlatform {
|
|
let goOS: String
|
|
let goArch: String
|
|
}
|
|
|
|
private struct DaemonHello {
|
|
let name: String
|
|
let version: String
|
|
let capabilities: [String]
|
|
let remotePath: String
|
|
}
|
|
|
|
private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility)
|
|
private weak var workspace: Workspace?
|
|
private let configuration: WorkspaceRemoteConfiguration
|
|
|
|
private var isStopping = false
|
|
private var probeProcess: Process?
|
|
private var probeStdoutPipe: Pipe?
|
|
private var probeStderrPipe: Pipe?
|
|
private var probeStdoutBuffer = ""
|
|
private var probeStderrBuffer = ""
|
|
|
|
private var desiredRemotePorts: Set<Int> = []
|
|
private var forwardEntries: [Int: ForwardEntry] = [:]
|
|
private var portConflicts: Set<Int> = []
|
|
private var daemonReady = false
|
|
private var daemonBootstrapVersion: String?
|
|
private var daemonRemotePath: String?
|
|
private var reconnectRetryCount = 0
|
|
private var reconnectWorkItem: DispatchWorkItem?
|
|
private var reverseRelayProcess: Process?
|
|
private var reverseRelayStderrPipe: Pipe?
|
|
|
|
init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) {
|
|
self.workspace = workspace
|
|
self.configuration = configuration
|
|
}
|
|
|
|
func start() {
|
|
queue.async { [weak self] in
|
|
guard let self else { return }
|
|
guard !self.isStopping else { return }
|
|
self.beginConnectionAttemptLocked()
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
queue.async { [weak self] in
|
|
self?.stopAllLocked()
|
|
}
|
|
}
|
|
|
|
private func stopAllLocked() {
|
|
isStopping = true
|
|
reconnectWorkItem?.cancel()
|
|
reconnectWorkItem = nil
|
|
reconnectRetryCount = 0
|
|
|
|
if let probeProcess {
|
|
probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
|
probeStderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
if probeProcess.isRunning {
|
|
probeProcess.terminate()
|
|
}
|
|
}
|
|
probeProcess = nil
|
|
probeStdoutPipe = nil
|
|
probeStderrPipe = nil
|
|
probeStdoutBuffer = ""
|
|
probeStderrBuffer = ""
|
|
|
|
if let reverseRelayProcess {
|
|
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
if reverseRelayProcess.isRunning {
|
|
reverseRelayProcess.terminate()
|
|
}
|
|
}
|
|
reverseRelayProcess = nil
|
|
reverseRelayStderrPipe = nil
|
|
|
|
for (_, entry) in forwardEntries {
|
|
entry.stderrPipe.fileHandleForReading.readabilityHandler = nil
|
|
if entry.process.isRunning {
|
|
entry.process.terminate()
|
|
}
|
|
}
|
|
forwardEntries.removeAll()
|
|
desiredRemotePorts.removeAll()
|
|
portConflicts.removeAll()
|
|
daemonReady = false
|
|
daemonBootstrapVersion = nil
|
|
daemonRemotePath = nil
|
|
}
|
|
|
|
private func beginConnectionAttemptLocked() {
|
|
guard !isStopping else { return }
|
|
|
|
reconnectWorkItem = nil
|
|
let connectDetail: String
|
|
let bootstrapDetail: String
|
|
if reconnectRetryCount > 0 {
|
|
connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))"
|
|
bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))"
|
|
} else {
|
|
connectDetail = "Connecting to \(configuration.displayTarget)"
|
|
bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)"
|
|
}
|
|
publishState(.connecting, detail: connectDetail)
|
|
publishDaemonStatus(.bootstrapping, detail: bootstrapDetail)
|
|
|
|
do {
|
|
let hello = try bootstrapDaemonLocked()
|
|
daemonReady = true
|
|
daemonBootstrapVersion = hello.version
|
|
daemonRemotePath = hello.remotePath
|
|
publishDaemonStatus(
|
|
.ready,
|
|
detail: "Remote daemon ready",
|
|
version: hello.version,
|
|
name: hello.name,
|
|
capabilities: hello.capabilities,
|
|
remotePath: hello.remotePath
|
|
)
|
|
startReverseRelayLocked()
|
|
startProbeLocked()
|
|
} catch {
|
|
daemonReady = false
|
|
daemonBootstrapVersion = nil
|
|
daemonRemotePath = nil
|
|
let nextRetry = scheduleProbeRestartLocked(delay: 4.0)
|
|
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0)
|
|
let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)"
|
|
publishDaemonStatus(.error, detail: detail)
|
|
publishState(.error, detail: detail)
|
|
}
|
|
}
|
|
|
|
private func startProbeLocked() {
|
|
guard !isStopping else { return }
|
|
guard daemonReady else { return }
|
|
|
|
probeStdoutBuffer = ""
|
|
probeStderrBuffer = ""
|
|
|
|
let process = Process()
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
process.arguments = probeArguments()
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
|
|
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
return
|
|
}
|
|
self?.queue.async {
|
|
self?.consumeProbeStdoutData(data)
|
|
}
|
|
}
|
|
|
|
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
if data.isEmpty {
|
|
handle.readabilityHandler = nil
|
|
return
|
|
}
|
|
self?.queue.async {
|
|
self?.consumeProbeStderrData(data)
|
|
}
|
|
}
|
|
|
|
process.terminationHandler = { [weak self] terminated in
|
|
self?.queue.async {
|
|
self?.handleProbeTermination(terminated)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
probeProcess = process
|
|
probeStdoutPipe = stdoutPipe
|
|
probeStderrPipe = stderrPipe
|
|
} catch {
|
|
let nextRetry = scheduleProbeRestartLocked(delay: 3.0)
|
|
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0)
|
|
publishState(.error, detail: "Failed to start SSH probe: \(error.localizedDescription)\(retrySuffix)")
|
|
}
|
|
}
|
|
|
|
private func handleProbeTermination(_ process: Process) {
|
|
probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
|
probeStderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
probeProcess = nil
|
|
probeStdoutPipe = nil
|
|
probeStderrPipe = nil
|
|
|
|
guard !isStopping else { return }
|
|
|
|
for (_, entry) in forwardEntries {
|
|
entry.stderrPipe.fileHandleForReading.readabilityHandler = nil
|
|
if entry.process.isRunning {
|
|
entry.process.terminate()
|
|
}
|
|
}
|
|
forwardEntries.removeAll()
|
|
publishPortsSnapshotLocked()
|
|
|
|
let statusCode = process.terminationStatus
|
|
let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer, stdout: probeStdoutBuffer)
|
|
let detail = rawDetail ?? "SSH probe exited with status \(statusCode)"
|
|
let nextRetry = scheduleProbeRestartLocked(delay: 3.0)
|
|
let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0)
|
|
publishState(.error, detail: "SSH probe to \(configuration.displayTarget) failed: \(detail)\(retrySuffix)")
|
|
}
|
|
|
|
@discardableResult
|
|
private func scheduleProbeRestartLocked(delay: TimeInterval) -> Int {
|
|
guard !isStopping else { return reconnectRetryCount }
|
|
reconnectWorkItem?.cancel()
|
|
reconnectRetryCount += 1
|
|
let retryNumber = reconnectRetryCount
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
self.reconnectWorkItem = nil
|
|
guard !self.isStopping else { return }
|
|
guard self.probeProcess == nil else { return }
|
|
self.beginConnectionAttemptLocked()
|
|
}
|
|
reconnectWorkItem = workItem
|
|
queue.asyncAfter(deadline: .now() + delay, execute: workItem)
|
|
return retryNumber
|
|
}
|
|
|
|
private func consumeProbeStdoutData(_ data: Data) {
|
|
guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return }
|
|
probeStdoutBuffer.append(chunk)
|
|
|
|
while let newline = probeStdoutBuffer.firstIndex(of: "\n") {
|
|
let line = String(probeStdoutBuffer[..<newline])
|
|
probeStdoutBuffer.removeSubrange(...newline)
|
|
handleProbePortsLine(line)
|
|
}
|
|
}
|
|
|
|
private func consumeProbeStderrData(_ data: Data) {
|
|
guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return }
|
|
probeStderrBuffer.append(chunk)
|
|
if probeStderrBuffer.count > 8192 {
|
|
probeStderrBuffer.removeFirst(probeStderrBuffer.count - 8192)
|
|
}
|
|
}
|
|
|
|
private func handleProbePortsLine(_ line: String) {
|
|
guard !isStopping else { return }
|
|
|
|
var ports = Set(Self.parseRemotePorts(line: line))
|
|
if let relayPort = configuration.relayPort {
|
|
ports.remove(relayPort)
|
|
}
|
|
// Filter ephemeral ports (49152-65535) — these are SSH reverse relay ports
|
|
// from this or other workspaces, not user services worth forwarding.
|
|
ports = ports.filter { $0 < 49152 }
|
|
desiredRemotePorts = ports
|
|
portConflicts = portConflicts.intersection(desiredRemotePorts)
|
|
reconnectWorkItem?.cancel()
|
|
reconnectWorkItem = nil
|
|
reconnectRetryCount = 0
|
|
publishState(.connected, detail: "Connected to \(configuration.displayTarget)")
|
|
reconcileForwardsLocked()
|
|
}
|
|
|
|
private func reconcileForwardsLocked() {
|
|
guard !isStopping else { return }
|
|
|
|
for (port, entry) in forwardEntries where !desiredRemotePorts.contains(port) {
|
|
entry.stderrPipe.fileHandleForReading.readabilityHandler = nil
|
|
if entry.process.isRunning {
|
|
entry.process.terminate()
|
|
}
|
|
forwardEntries.removeValue(forKey: port)
|
|
}
|
|
|
|
for port in desiredRemotePorts.sorted() where forwardEntries[port] == nil {
|
|
guard Self.isLoopbackPortAvailable(port: port) else {
|
|
// Port is already bound locally. If it's reachable (e.g. another
|
|
// workspace is forwarding it), don't flag it as a conflict.
|
|
if Self.isLoopbackPortReachable(port: port) {
|
|
portConflicts.remove(port)
|
|
} else {
|
|
portConflicts.insert(port)
|
|
}
|
|
continue
|
|
}
|
|
if startForwardLocked(port: port) {
|
|
portConflicts.remove(port)
|
|
} else {
|
|
portConflicts.insert(port)
|
|
}
|
|
}
|
|
|
|
publishPortsSnapshotLocked()
|
|
}
|
|
|
|
@discardableResult
|
|
private func startForwardLocked(port: Int) -> Bool {
|
|
guard !isStopping else { return false }
|
|
|
|
let process = Process()
|
|
let stderrPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
process.arguments = forwardArguments(port: port)
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = stderrPipe
|
|
|
|
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty else {
|
|
handle.readabilityHandler = nil
|
|
return
|
|
}
|
|
self?.queue.async {
|
|
guard let self else { return }
|
|
if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty {
|
|
self.probeStderrBuffer.append(chunk)
|
|
if self.probeStderrBuffer.count > 8192 {
|
|
self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
process.terminationHandler = { [weak self] terminated in
|
|
self?.queue.async {
|
|
self?.handleForwardTermination(port: port, process: terminated)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
forwardEntries[port] = ForwardEntry(process: process, stderrPipe: stderrPipe)
|
|
return true
|
|
} catch {
|
|
publishState(.error, detail: "Failed to forward local :\(port) to \(configuration.displayTarget): \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func handleForwardTermination(port: Int, process: Process) {
|
|
if let current = forwardEntries[port], current.process === process {
|
|
current.stderrPipe.fileHandleForReading.readabilityHandler = nil
|
|
forwardEntries.removeValue(forKey: port)
|
|
}
|
|
|
|
guard !isStopping else { return }
|
|
publishPortsSnapshotLocked()
|
|
|
|
guard desiredRemotePorts.contains(port) else { return }
|
|
let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer)
|
|
if process.terminationReason != .exit || process.terminationStatus != 0 {
|
|
let detail = rawDetail ?? "process exited with status \(process.terminationStatus)"
|
|
publishState(.error, detail: "SSH port-forward :\(port) dropped for \(configuration.displayTarget): \(detail)")
|
|
}
|
|
guard Self.isLoopbackPortAvailable(port: port) else {
|
|
portConflicts.insert(port)
|
|
publishPortsSnapshotLocked()
|
|
return
|
|
}
|
|
|
|
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
guard let self else { return }
|
|
guard !self.isStopping else { return }
|
|
guard self.desiredRemotePorts.contains(port) else { return }
|
|
guard self.forwardEntries[port] == nil else { return }
|
|
if self.startForwardLocked(port: port) {
|
|
self.portConflicts.remove(port)
|
|
} else {
|
|
self.portConflicts.insert(port)
|
|
}
|
|
self.publishPortsSnapshotLocked()
|
|
}
|
|
}
|
|
|
|
/// Spawns a background SSH process that reverse-forwards a remote TCP port to the local cmux Unix socket.
|
|
/// This process is a direct child of the cmux app, so it passes the `isDescendant()` ancestry check.
|
|
@discardableResult
|
|
private func startReverseRelayLocked() -> Bool {
|
|
guard !isStopping else { return false }
|
|
guard let relayPort = configuration.relayPort, relayPort > 0,
|
|
let localSocketPath = configuration.localSocketPath, !localSocketPath.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
// Kill any existing relay process managed by this session
|
|
if let existing = reverseRelayProcess {
|
|
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
if existing.isRunning { existing.terminate() }
|
|
reverseRelayProcess = nil
|
|
reverseRelayStderrPipe = nil
|
|
}
|
|
|
|
// Kill orphaned relay SSH processes from previous app sessions that reverse-forward
|
|
// to the same socket path (they survive pkill because they're reparented to launchd).
|
|
Self.killOrphanedRelayProcesses(
|
|
relayPort: relayPort,
|
|
socketPath: localSocketPath,
|
|
destination: configuration.destination
|
|
)
|
|
|
|
let process = Process()
|
|
let stderrPipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
|
|
// Build arguments: -N (no remote command), -o ControlPath=none (avoid ControlMaster delegation),
|
|
// then common SSH args, then -R reverse forward, then destination.
|
|
// ExitOnForwardFailure=no because user's ~/.ssh/config may have RemoteForward entries
|
|
// that conflict with already-bound ports — we don't want those to kill our relay.
|
|
var args: [String] = ["-N"]
|
|
args += sshCommonArguments(batchMode: true)
|
|
args += ["-R", "127.0.0.1:\(relayPort):\(localSocketPath)"]
|
|
args += [configuration.destination]
|
|
process.arguments = args
|
|
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = stderrPipe
|
|
|
|
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
|
let data = handle.availableData
|
|
guard !data.isEmpty else {
|
|
handle.readabilityHandler = nil
|
|
return
|
|
}
|
|
self?.queue.async {
|
|
guard let self else { return }
|
|
if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty {
|
|
self.probeStderrBuffer.append(chunk)
|
|
if self.probeStderrBuffer.count > 8192 {
|
|
self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
process.terminationHandler = { [weak self] terminated in
|
|
self?.queue.async {
|
|
self?.handleReverseRelayTermination(process: terminated)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
reverseRelayProcess = process
|
|
reverseRelayStderrPipe = stderrPipe
|
|
NSLog("[cmux] reverse relay started: -R 127.0.0.1:%d:%@ → %@", relayPort, localSocketPath, configuration.destination)
|
|
|
|
// Write socket_addr after a delay to give the SSH -R forward time to establish.
|
|
// The Go CLI retry loop re-reads this file, so it will pick up the port once ready.
|
|
queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
|
guard let self, !self.isStopping else { return }
|
|
guard self.reverseRelayProcess?.isRunning == true else { return }
|
|
self.writeRemoteSocketAddrLocked()
|
|
self.writeRemoteRelayDaemonMappingLocked()
|
|
}
|
|
|
|
return true
|
|
} catch {
|
|
NSLog("[cmux] failed to start reverse relay: %@", error.localizedDescription)
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func handleReverseRelayTermination(process: Process) {
|
|
if reverseRelayProcess === process {
|
|
reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil
|
|
reverseRelayProcess = nil
|
|
reverseRelayStderrPipe = nil
|
|
}
|
|
|
|
guard !isStopping else { return }
|
|
guard configuration.relayPort != nil else { return }
|
|
|
|
// Auto-restart after 2 seconds if we're still active
|
|
queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
guard let self else { return }
|
|
guard !self.isStopping else { return }
|
|
guard self.reverseRelayProcess == nil else { return }
|
|
self.startReverseRelayLocked()
|
|
}
|
|
}
|
|
|
|
private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) {
|
|
DispatchQueue.main.async { [weak workspace] in
|
|
guard let workspace else { return }
|
|
workspace.applyRemoteConnectionStateUpdate(
|
|
state,
|
|
detail: detail,
|
|
target: workspace.remoteDisplayTarget ?? "remote host"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func publishDaemonStatus(
|
|
_ state: WorkspaceRemoteDaemonState,
|
|
detail: String?,
|
|
version: String? = nil,
|
|
name: String? = nil,
|
|
capabilities: [String] = [],
|
|
remotePath: String? = nil
|
|
) {
|
|
let status = WorkspaceRemoteDaemonStatus(
|
|
state: state,
|
|
detail: detail,
|
|
version: version,
|
|
name: name,
|
|
capabilities: capabilities,
|
|
remotePath: remotePath
|
|
)
|
|
DispatchQueue.main.async { [weak workspace] in
|
|
guard let workspace else { return }
|
|
workspace.applyRemoteDaemonStatusUpdate(
|
|
status,
|
|
target: workspace.remoteDisplayTarget ?? "remote host"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func publishPortsSnapshotLocked() {
|
|
let detected = desiredRemotePorts.sorted()
|
|
let forwarded = forwardEntries.keys.sorted()
|
|
let conflicts = portConflicts.sorted()
|
|
DispatchQueue.main.async { [weak workspace] in
|
|
guard let workspace else { return }
|
|
workspace.applyRemotePortsSnapshot(
|
|
detected: detected,
|
|
forwarded: forwarded,
|
|
conflicts: conflicts,
|
|
target: workspace.remoteDisplayTarget ?? "remote host"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func probeArguments() -> [String] {
|
|
let remoteScript = Self.probeScript()
|
|
let remoteCommand = "sh -lc \(Self.shellSingleQuoted(remoteScript))"
|
|
return sshCommonArguments(batchMode: true) + [configuration.destination, remoteCommand]
|
|
}
|
|
|
|
private func forwardArguments(port: Int) -> [String] {
|
|
let localBind = "127.0.0.1:\(port):127.0.0.1:\(port)"
|
|
return ["-N"] + sshCommonArguments(batchMode: true) + ["-L", localBind, configuration.destination]
|
|
}
|
|
|
|
private func sshCommonArguments(batchMode: Bool) -> [String] {
|
|
var args: [String] = [
|
|
"-o", "ConnectTimeout=6",
|
|
"-o", "ServerAliveInterval=20",
|
|
"-o", "ServerAliveCountMax=2",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ExitOnForwardFailure=no",
|
|
"-o", "ControlPath=none",
|
|
]
|
|
if batchMode {
|
|
args += ["-o", "BatchMode=yes"]
|
|
}
|
|
if let port = configuration.port {
|
|
args += ["-p", String(port)]
|
|
}
|
|
if let identityFile = configuration.identityFile,
|
|
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
args += ["-i", identityFile]
|
|
}
|
|
for option in configuration.sshOptions {
|
|
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
args += ["-o", trimmed]
|
|
}
|
|
return args
|
|
}
|
|
|
|
private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult {
|
|
try runProcess(
|
|
executable: "/usr/bin/ssh",
|
|
arguments: arguments,
|
|
stdin: stdin,
|
|
timeout: timeout
|
|
)
|
|
}
|
|
|
|
private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult {
|
|
try runProcess(
|
|
executable: "/usr/bin/scp",
|
|
arguments: arguments,
|
|
stdin: nil,
|
|
timeout: timeout
|
|
)
|
|
}
|
|
|
|
private func runProcess(
|
|
executable: String,
|
|
arguments: [String],
|
|
environment: [String: String]? = nil,
|
|
currentDirectory: URL? = nil,
|
|
stdin: Data?,
|
|
timeout: TimeInterval
|
|
) throws -> CommandResult {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: executable)
|
|
process.arguments = arguments
|
|
if let environment {
|
|
process.environment = environment
|
|
}
|
|
if let currentDirectory {
|
|
process.currentDirectoryURL = currentDirectory
|
|
}
|
|
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
|
|
if stdin != nil {
|
|
process.standardInput = Pipe()
|
|
} else {
|
|
process.standardInput = FileHandle.nullDevice
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [
|
|
NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)",
|
|
])
|
|
}
|
|
|
|
if let stdin, let pipe = process.standardInput as? Pipe {
|
|
pipe.fileHandleForWriting.write(stdin)
|
|
try? pipe.fileHandleForWriting.close()
|
|
}
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while process.isRunning && Date() < deadline {
|
|
Thread.sleep(forTimeInterval: 0.05)
|
|
}
|
|
if process.isRunning {
|
|
process.terminate()
|
|
throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [
|
|
NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s",
|
|
])
|
|
}
|
|
|
|
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let stdout = String(data: stdoutData, encoding: .utf8) ?? ""
|
|
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
|
|
return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr)
|
|
}
|
|
|
|
private func bootstrapDaemonLocked() throws -> DaemonHello {
|
|
let platform = try resolveRemotePlatformLocked()
|
|
let version = Self.remoteDaemonVersion()
|
|
let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch)
|
|
|
|
if try !remoteDaemonExistsLocked(remotePath: remotePath) {
|
|
let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version)
|
|
try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath)
|
|
}
|
|
|
|
createRemoteCLISymlinkLocked(daemonRemotePath: remotePath)
|
|
|
|
return try helloRemoteDaemonLocked(remotePath: remotePath)
|
|
}
|
|
|
|
/// Installs a stable `cmux` wrapper on the remote and updates the default daemon target.
|
|
/// The wrapper resolves daemon path using relay-port metadata, allowing multiple local
|
|
/// cmux versions to coexist on the same remote host without clobbering each other.
|
|
/// Tries `/usr/local/bin` first (already in PATH, no rc changes needed), falls back to
|
|
/// `~/.cmux/bin`. Non-fatal: logs on failure but does not throw.
|
|
private func createRemoteCLISymlinkLocked(daemonRemotePath: String) {
|
|
let script = """
|
|
mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay"
|
|
ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current"
|
|
cat > "$HOME/.cmux/bin/cmux" <<'CMUX_REMOTE_WRAPPER'
|
|
#!/bin/sh
|
|
set -eu
|
|
|
|
if [ -n "${CMUX_SOCKET_PATH:-}" ]; then
|
|
_cmux_port="${CMUX_SOCKET_PATH##*:}"
|
|
case "$_cmux_port" in
|
|
''|*[!0-9]*)
|
|
;;
|
|
*)
|
|
_cmux_map="$HOME/.cmux/relay/${_cmux_port}.daemon_path"
|
|
if [ -r "$_cmux_map" ]; then
|
|
_cmux_daemon="$(cat "$_cmux_map" 2>/dev/null || true)"
|
|
if [ -n "$_cmux_daemon" ] && [ -x "$_cmux_daemon" ]; then
|
|
exec "$_cmux_daemon" cli "$@"
|
|
fi
|
|
fi
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
if [ -x "$HOME/.cmux/bin/cmuxd-remote-current" ]; then
|
|
exec "$HOME/.cmux/bin/cmuxd-remote-current" cli "$@"
|
|
fi
|
|
|
|
echo "cmux: remote daemon not installed; reconnect from local cmux." >&2
|
|
exit 127
|
|
CMUX_REMOTE_WRAPPER
|
|
chmod 755 "$HOME/.cmux/bin/cmux"
|
|
ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \
|
|
|| sudo -n ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \
|
|
|| true
|
|
"""
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
do {
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
|
|
if result.status != 0 {
|
|
NSLog("[cmux] warning: failed to create remote CLI symlink (exit %d): %@",
|
|
result.status,
|
|
Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error")
|
|
}
|
|
} catch {
|
|
NSLog("[cmux] warning: failed to create remote CLI symlink: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Writes `~/.cmux/socket_addr` on the remote with the relay TCP address.
|
|
/// The Go CLI relay reads this file as a fallback when CMUX_SOCKET_PATH is not set.
|
|
private func writeRemoteSocketAddrLocked() {
|
|
guard let relayPort = configuration.relayPort, relayPort > 0 else { return }
|
|
let addr = "127.0.0.1:\(relayPort)"
|
|
let script = "mkdir -p \"$HOME/.cmux\" && printf '%s' '\(addr)' > \"$HOME/.cmux/socket_addr\""
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
do {
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
|
|
if result.status != 0 {
|
|
NSLog("[cmux] warning: failed to write remote socket_addr (exit %d): %@",
|
|
result.status,
|
|
Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error")
|
|
}
|
|
} catch {
|
|
NSLog("[cmux] warning: failed to write remote socket_addr: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Writes relay-port -> daemon binary mapping used by the remote `cmux` wrapper.
|
|
/// This keeps CLI dispatch stable when multiple local cmux versions target the same host.
|
|
private func writeRemoteRelayDaemonMappingLocked() {
|
|
guard let relayPort = configuration.relayPort, relayPort > 0,
|
|
let daemonRemotePath, !daemonRemotePath.isEmpty else { return }
|
|
let script = """
|
|
mkdir -p "$HOME/.cmux/relay" && \
|
|
printf '%s' "$HOME/\(daemonRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path"
|
|
"""
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
do {
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
|
|
if result.status != 0 {
|
|
NSLog("[cmux] warning: failed to write remote relay daemon mapping (exit %d): %@",
|
|
result.status,
|
|
Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error")
|
|
}
|
|
} catch {
|
|
NSLog("[cmux] warning: failed to write remote relay daemon mapping: %@", error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func resolveRemotePlatformLocked() throws -> RemotePlatform {
|
|
let script = "uname -s; uname -m"
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 10)
|
|
guard result.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)",
|
|
])
|
|
}
|
|
|
|
let lines = result.stdout
|
|
.split(separator: "\n")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard lines.count >= 2 else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [
|
|
NSLocalizedDescriptionKey: "remote platform probe returned invalid output",
|
|
])
|
|
}
|
|
|
|
guard let goOS = Self.mapUnameOS(lines[0]),
|
|
let goArch = Self.mapUnameArch(lines[1]) else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [
|
|
NSLocalizedDescriptionKey: "unsupported remote platform \(lines[0])/\(lines[1])",
|
|
])
|
|
}
|
|
|
|
return RemotePlatform(goOS: goOS, goArch: goArch)
|
|
}
|
|
|
|
private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool {
|
|
let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi"
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8)
|
|
guard result.status == 0 else { return false }
|
|
return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes"
|
|
}
|
|
|
|
private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL {
|
|
guard let repoRoot = Self.findRepoRoot() else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [
|
|
NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build",
|
|
])
|
|
}
|
|
let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true)
|
|
let goModPath = daemonRoot.appendingPathComponent("go.mod").path
|
|
guard FileManager.default.fileExists(atPath: goModPath) else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [
|
|
NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)",
|
|
])
|
|
}
|
|
guard let goBinary = Self.which("go") else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [
|
|
NSLocalizedDescriptionKey: "go is required to build cmuxd-remote",
|
|
])
|
|
}
|
|
|
|
let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
|
.appendingPathComponent("cmux-remote-daemon-build", isDirectory: true)
|
|
.appendingPathComponent(version, isDirectory: true)
|
|
.appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
|
|
let output = cacheRoot.appendingPathComponent("cmuxd-remote", isDirectory: false)
|
|
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["GOOS"] = goOS
|
|
env["GOARCH"] = goArch
|
|
env["CGO_ENABLED"] = "0"
|
|
let ldflags = "-s -w -X main.version=\(version)"
|
|
let result = try runProcess(
|
|
executable: goBinary,
|
|
arguments: ["build", "-trimpath", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"],
|
|
environment: env,
|
|
currentDirectory: daemonRoot,
|
|
stdin: nil,
|
|
timeout: 90
|
|
)
|
|
guard result.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)",
|
|
])
|
|
}
|
|
guard FileManager.default.isExecutableFile(atPath: output.path) else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [
|
|
NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable",
|
|
])
|
|
}
|
|
return output
|
|
}
|
|
|
|
private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws {
|
|
let remoteDirectory = (remotePath as NSString).deletingLastPathComponent
|
|
let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))"
|
|
|
|
let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))"
|
|
let mkdirCommand = "sh -lc \(Self.shellSingleQuoted(mkdirScript))"
|
|
let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12)
|
|
guard mkdirResult.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)",
|
|
])
|
|
}
|
|
|
|
var scpArgs: [String] = ["-q", "-o", "StrictHostKeyChecking=accept-new"]
|
|
if let port = configuration.port {
|
|
scpArgs += ["-P", String(port)]
|
|
}
|
|
if let identityFile = configuration.identityFile,
|
|
!identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
scpArgs += ["-i", identityFile]
|
|
}
|
|
for option in configuration.sshOptions {
|
|
let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { continue }
|
|
scpArgs += ["-o", trimmed]
|
|
}
|
|
scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"]
|
|
let scpResult = try scpExec(arguments: scpArgs, timeout: 45)
|
|
guard scpResult.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)",
|
|
])
|
|
}
|
|
|
|
let finalizeScript = """
|
|
chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \
|
|
mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath))
|
|
"""
|
|
let finalizeCommand = "sh -lc \(Self.shellSingleQuoted(finalizeScript))"
|
|
let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12)
|
|
guard finalizeResult.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello {
|
|
let request = #"{"id":1,"method":"hello","params":{}}"#
|
|
let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio"
|
|
let command = "sh -lc \(Self.shellSingleQuoted(script))"
|
|
let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12)
|
|
guard result.status == 0 else {
|
|
let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)"
|
|
throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [
|
|
NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)",
|
|
])
|
|
}
|
|
|
|
let responseLine = result.stdout
|
|
.split(separator: "\n")
|
|
.map(String.init)
|
|
.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? ""
|
|
guard !responseLine.isEmpty,
|
|
let data = responseLine.data(using: .utf8),
|
|
let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
|
throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [
|
|
NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON",
|
|
])
|
|
}
|
|
|
|
if let ok = payload["ok"] as? Bool, !ok {
|
|
let errorMessage: String = {
|
|
if let errorObject = payload["error"] as? [String: Any],
|
|
let message = errorObject["message"] as? String,
|
|
!message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return message
|
|
}
|
|
return "hello call failed"
|
|
}()
|
|
throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [
|
|
NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)",
|
|
])
|
|
}
|
|
|
|
let resultObject = payload["result"] as? [String: Any] ?? [:]
|
|
let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let capabilities = (resultObject["capabilities"] as? [String]) ?? []
|
|
return DaemonHello(
|
|
name: (name?.isEmpty == false ? name! : "cmuxd-remote"),
|
|
version: (version?.isEmpty == false ? version! : "dev"),
|
|
capabilities: capabilities,
|
|
remotePath: remotePath
|
|
)
|
|
}
|
|
|
|
private static func parseRemotePorts(line: String) -> [Int] {
|
|
let tokens = line.split(whereSeparator: \.isWhitespace)
|
|
let values = tokens.compactMap { Int($0) }
|
|
let filtered = values.filter { $0 >= 1024 && $0 <= 65535 }
|
|
let unique = Set(filtered)
|
|
if unique.count <= 40 {
|
|
return unique.sorted()
|
|
}
|
|
return Array(unique.sorted().prefix(40))
|
|
}
|
|
|
|
private static func probeScript() -> String {
|
|
"""
|
|
set -eu
|
|
# Force an initial emission so the controller can transition out of
|
|
# "connecting" even when no ports are detected.
|
|
CMUX_LAST="__cmux_init__"
|
|
while true; do
|
|
if command -v ss >/dev/null 2>&1; then
|
|
PORTS="$(ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')"
|
|
elif command -v netstat >/dev/null 2>&1; then
|
|
PORTS="$(netstat -lnt 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')"
|
|
else
|
|
PORTS=""
|
|
fi
|
|
if [ "$PORTS" != "$CMUX_LAST" ]; then
|
|
echo "$PORTS"
|
|
CMUX_LAST="$PORTS"
|
|
fi
|
|
sleep 2
|
|
done
|
|
"""
|
|
}
|
|
|
|
private static func shellSingleQuoted(_ value: String) -> String {
|
|
"'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
|
}
|
|
|
|
private static func mapUnameOS(_ raw: String) -> String? {
|
|
switch raw.lowercased() {
|
|
case "linux":
|
|
return "linux"
|
|
case "darwin":
|
|
return "darwin"
|
|
case "freebsd":
|
|
return "freebsd"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func mapUnameArch(_ raw: String) -> String? {
|
|
switch raw.lowercased() {
|
|
case "x86_64", "amd64":
|
|
return "amd64"
|
|
case "aarch64", "arm64":
|
|
return "arm64"
|
|
case "armv7l":
|
|
return "arm"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func remoteDaemonVersion() -> String {
|
|
let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let bundleVersion, !bundleVersion.isEmpty {
|
|
return bundleVersion
|
|
}
|
|
return "dev"
|
|
}
|
|
|
|
private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String {
|
|
".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote"
|
|
}
|
|
|
|
private static func which(_ executable: String) -> String? {
|
|
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
|
for component in path.split(separator: ":") {
|
|
let candidate = String(component) + "/" + executable
|
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func findRepoRoot() -> URL? {
|
|
var candidates: [URL] = []
|
|
let compileTimeRoot = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent() // Sources
|
|
.deletingLastPathComponent() // repo root
|
|
candidates.append(compileTimeRoot)
|
|
let environment = ProcessInfo.processInfo.environment
|
|
if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"],
|
|
!envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true))
|
|
}
|
|
if let envRoot = environment["CMUXTERM_REPO_ROOT"],
|
|
!envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true))
|
|
}
|
|
candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true))
|
|
if let executable = Bundle.main.executableURL?.deletingLastPathComponent() {
|
|
candidates.append(executable)
|
|
candidates.append(executable.deletingLastPathComponent())
|
|
candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent())
|
|
}
|
|
|
|
let fm = FileManager.default
|
|
for base in candidates {
|
|
var cursor = base.standardizedFileURL
|
|
for _ in 0..<10 {
|
|
let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path
|
|
if fm.fileExists(atPath: marker) {
|
|
return cursor
|
|
}
|
|
let parent = cursor.deletingLastPathComponent()
|
|
if parent.path == cursor.path {
|
|
break
|
|
}
|
|
cursor = parent
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func bestErrorLine(stderr: String, stdout: String = "") -> String? {
|
|
if let stderrLine = meaningfulErrorLine(in: stderr) {
|
|
return stderrLine
|
|
}
|
|
if let stdoutLine = meaningfulErrorLine(in: stdout) {
|
|
return stdoutLine
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func meaningfulErrorLine(in text: String) -> String? {
|
|
let lines = text
|
|
.split(separator: "\n")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
for line in lines.reversed() where !isNoiseLine(line) {
|
|
return line
|
|
}
|
|
return lines.last
|
|
}
|
|
|
|
private static func isNoiseLine(_ line: String) -> Bool {
|
|
let lowered = line.lowercased()
|
|
if lowered.hasPrefix("warning: permanently added") { return true }
|
|
if lowered.hasPrefix("debug") { return true }
|
|
if lowered.hasPrefix("transferred:") { return true }
|
|
if lowered.hasPrefix("openbsd_") { return true }
|
|
if lowered.contains("pseudo-terminal will not be allocated") { return true }
|
|
return false
|
|
}
|
|
|
|
private static func retrySuffix(retry: Int, delay: TimeInterval) -> String {
|
|
let seconds = max(1, Int(delay.rounded()))
|
|
return " (retry \(retry) in \(seconds)s)"
|
|
}
|
|
|
|
/// Kills orphaned SSH relay processes from previous app sessions.
|
|
/// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup.
|
|
private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) {
|
|
guard relayPort > 0 else { return }
|
|
let pipe = Pipe()
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
|
let socketPathPattern = NSRegularExpression.escapedPattern(for: socketPath)
|
|
let destinationPattern = NSRegularExpression.escapedPattern(for: destination)
|
|
let relayPattern = "ssh.*-R[[:space:]]*127\\.0\\.0\\.1:\(relayPort):\(socketPathPattern).*\(destinationPattern)"
|
|
process.arguments = ["-f", relayPattern]
|
|
process.standardOutput = pipe
|
|
process.standardError = pipe
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
} catch {
|
|
// Best-effort cleanup; ignore failures
|
|
}
|
|
}
|
|
|
|
private static func isLoopbackPortAvailable(port: Int) -> Bool {
|
|
guard port > 0 && port <= 65535 else { return false }
|
|
|
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
|
guard fd >= 0 else { return false }
|
|
defer { close(fd) }
|
|
|
|
var yes: Int32 = 1
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
|
|
|
|
var addr = sockaddr_in()
|
|
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
addr.sin_family = sa_family_t(AF_INET)
|
|
addr.sin_port = in_port_t(UInt16(port).bigEndian)
|
|
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
|
|
|
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
return bindResult == 0
|
|
}
|
|
|
|
/// Check if a port on 127.0.0.1 is already accepting connections (e.g. forwarded by another workspace).
|
|
private static func isLoopbackPortReachable(port: Int) -> Bool {
|
|
guard port > 0 && port <= 65535 else { return false }
|
|
|
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
|
guard fd >= 0 else { return false }
|
|
defer { close(fd) }
|
|
|
|
var addr = sockaddr_in()
|
|
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
addr.sin_family = sa_family_t(AF_INET)
|
|
addr.sin_port = in_port_t(UInt16(port).bigEndian)
|
|
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
|
|
|
let result = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
connect(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
return result == 0
|
|
}
|
|
}
|
|
|
|
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):
|
|
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
|
|
}
|
|
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) {
|
|
key = EntryKey(directory: directoryKey, branch: nil)
|
|
} else {
|
|
key = EntryKey(directory: nil, branch: branch)
|
|
}
|
|
|
|
if entries[key] == nil {
|
|
order.append(key)
|
|
entries[key] = MutableEntry(
|
|
branch: branch,
|
|
isDirty: panelDirty,
|
|
directory: directory
|
|
)
|
|
} else {
|
|
if panelDirty {
|
|
entries[key]?.isDirty = true
|
|
}
|
|
if let branch {
|
|
entries[key]?.branch = branch
|
|
}
|
|
if let directory {
|
|
entries[key]?.directory = directory
|
|
}
|
|
}
|
|
}
|
|
|
|
if order.isEmpty, let fallbackBranch {
|
|
let branch = normalized(fallbackBranch.branch)
|
|
let directory = normalized(defaultDirectory)
|
|
if branch != nil || directory != nil {
|
|
return [BranchDirectoryEntry(
|
|
branch: branch,
|
|
isDirty: fallbackBranch.isDirty,
|
|
directory: directory
|
|
)]
|
|
}
|
|
}
|
|
|
|
return order.compactMap { key in
|
|
guard let entry = entries[key] else { return nil }
|
|
return BranchDirectoryEntry(
|
|
branch: entry.branch,
|
|
isDirty: entry.isDirty,
|
|
directory: entry.directory
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum WorkspaceRemoteConnectionState: String {
|
|
case disconnected
|
|
case connecting
|
|
case connected
|
|
case error
|
|
}
|
|
|
|
enum WorkspaceRemoteDaemonState: String {
|
|
case unavailable
|
|
case bootstrapping
|
|
case ready
|
|
case error
|
|
}
|
|
|
|
struct WorkspaceRemoteDaemonStatus: Equatable {
|
|
var state: WorkspaceRemoteDaemonState = .unavailable
|
|
var detail: String?
|
|
var version: String?
|
|
var name: String?
|
|
var capabilities: [String] = []
|
|
var remotePath: String?
|
|
|
|
func payload() -> [String: Any] {
|
|
[
|
|
"state": state.rawValue,
|
|
"detail": detail ?? NSNull(),
|
|
"version": version ?? NSNull(),
|
|
"name": name ?? NSNull(),
|
|
"capabilities": capabilities,
|
|
"remote_path": remotePath ?? NSNull(),
|
|
]
|
|
}
|
|
}
|
|
|
|
struct WorkspaceRemoteConfiguration: Equatable {
|
|
let destination: String
|
|
let port: Int?
|
|
let identityFile: String?
|
|
let sshOptions: [String]
|
|
let relayPort: Int?
|
|
let localSocketPath: String?
|
|
|
|
var displayTarget: String {
|
|
guard let port else { return destination }
|
|
return "\(destination):\(port)"
|
|
}
|
|
}
|
|
|
|
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?
|
|
@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
|
|
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
|
|
|
|
nonisolated static func resolvedSnapshotTerminalScrollback(
|
|
capturedScrollback: String?,
|
|
fallbackScrollback: String?
|
|
) -> String? {
|
|
if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) {
|
|
return captured
|
|
}
|
|
return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback)
|
|
}
|
|
|
|
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?
|
|
switch panel.panelType {
|
|
case .terminal:
|
|
guard let _ = panel as? TerminalPanel else { return nil }
|
|
let resolvedScrollback = terminalSnapshotScrollback(
|
|
panelId: panelId,
|
|
capturedScrollback: nil,
|
|
includeScrollback: includeScrollback
|
|
)
|
|
terminalSnapshot = SessionTerminalPanelSnapshot(
|
|
workingDirectory: panelDirectories[panelId],
|
|
scrollback: resolvedScrollback
|
|
)
|
|
browserSnapshot = 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
|
|
)
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
|
|
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit
|
|
// layout/size synchronization.
|
|
|
|
/// The currently focused pane's panel ID
|
|
var focusedPanelId: UUID? {
|
|
guard let paneId = bonsplitController.focusedPaneId,
|
|
let tab = bonsplitController.selectedTab(inPane: paneId) else {
|
|
return nil
|
|
}
|
|
return panelIdFromSurfaceId(tab.id)
|
|
}
|
|
|
|
/// The currently focused terminal panel (if any)
|
|
var focusedTerminalPanel: TerminalPanel? {
|
|
guard let panelId = focusedPanelId,
|
|
let panel = panels[panelId] as? TerminalPanel else {
|
|
return nil
|
|
}
|
|
return panel
|
|
}
|
|
|
|
/// Published directory for each panel
|
|
@Published var panelDirectories: [UUID: String] = [:]
|
|
@Published var panelTitles: [UUID: String] = [:]
|
|
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
|
|
@Published private(set) var pinnedPanelIds: Set<UUID> = []
|
|
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
|
|
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
|
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
|
|
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
|
|
@Published var logEntries: [SidebarLogEntry] = []
|
|
@Published var progress: SidebarProgressState?
|
|
@Published var gitBranch: SidebarGitBranchState?
|
|
@Published var pullRequest: SidebarPullRequestState?
|
|
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
|
|
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
|
@Published var remoteConfiguration: WorkspaceRemoteConfiguration?
|
|
@Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected
|
|
@Published var remoteConnectionDetail: String?
|
|
@Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus()
|
|
@Published var remoteDetectedPorts: [Int] = []
|
|
@Published var remoteForwardedPorts: [Int] = []
|
|
@Published var remotePortConflicts: [Int] = []
|
|
@Published var listeningPorts: [Int] = []
|
|
var surfaceTTYNames: [UUID: String] = [:]
|
|
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
|
private var remoteSessionController: WorkspaceRemoteSessionController?
|
|
private var remoteLastErrorFingerprint: String?
|
|
private var remoteLastDaemonErrorFingerprint: String?
|
|
private var remoteLastPortConflictFingerprint: String?
|
|
|
|
private static let remoteErrorStatusKey = "remote.error"
|
|
private static let remotePortConflictStatusKey = "remote.port_conflicts"
|
|
|
|
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"
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
|
|
BonsplitConfiguration.SplitButtonTooltips(
|
|
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
|
|
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
|
|
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
|
|
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
|
|
)
|
|
}
|
|
|
|
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
|
|
bonsplitAppearance(from: config.backgroundColor)
|
|
}
|
|
|
|
nonisolated static func resolvedChromeColors(
|
|
from backgroundColor: NSColor
|
|
) -> BonsplitConfiguration.Appearance.ChromeColors {
|
|
.init(backgroundHex: backgroundColor.hexString())
|
|
}
|
|
|
|
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
|
|
let chromeColors = resolvedChromeColors(from: backgroundColor)
|
|
return BonsplitConfiguration.Appearance(
|
|
splitButtonTooltips: Self.currentSplitButtonTooltips(),
|
|
enableAnimations: false,
|
|
chromeColors: chromeColors
|
|
)
|
|
}
|
|
|
|
func applyGhosttyChrome(from config: GhosttyConfig) {
|
|
applyGhosttyChrome(backgroundColor: config.backgroundColor)
|
|
}
|
|
|
|
func applyGhosttyChrome(backgroundColor: NSColor) {
|
|
let nextHex = backgroundColor.hexString()
|
|
if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex {
|
|
return
|
|
}
|
|
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
|
|
}
|
|
|
|
init(
|
|
title: String = "Terminal",
|
|
workingDirectory: String? = nil,
|
|
portOrdinal: Int = 0,
|
|
initialTerminalCommand: String? = nil,
|
|
initialTerminalEnvironment: [String: String] = [:]
|
|
) {
|
|
self.id = UUID()
|
|
self.portOrdinal = portOrdinal
|
|
self.processTitle = title
|
|
self.title = title
|
|
self.customTitle = nil
|
|
self.customColor = 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.
|
|
let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load())
|
|
let config = BonsplitConfiguration(
|
|
allowSplits: true,
|
|
allowCloseTabs: true,
|
|
allowCloseLastPane: false,
|
|
allowTabReordering: true,
|
|
allowCrossPaneTabMove: true,
|
|
autoCloseEmptyPanes: true,
|
|
contentViewLifecycle: .keepAllAlive,
|
|
newTabPosition: .current,
|
|
appearance: appearance
|
|
)
|
|
self.bonsplitController = BonsplitController(configuration: config)
|
|
|
|
// Remove the default "Welcome" tab that bonsplit creates
|
|
let welcomeTabIds = bonsplitController.allTabIds
|
|
|
|
// Create initial terminal panel
|
|
let terminalPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_TAB,
|
|
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
|
|
portOrdinal: portOrdinal,
|
|
initialCommand: initialTerminalCommand,
|
|
initialEnvironmentOverrides: initialTerminalEnvironment
|
|
)
|
|
panels[terminalPanel.id] = terminalPanel
|
|
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
remoteSessionController?.stop()
|
|
}
|
|
|
|
func refreshSplitButtonTooltips() {
|
|
var configuration = bonsplitController.configuration
|
|
configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips()
|
|
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] = [:]
|
|
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
|
|
#endif
|
|
private var geometryReconcileScheduled = 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] = [:]
|
|
|
|
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
|
|
}
|
|
// 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
|
|
}
|
|
|
|
private func surfaceKind(for panel: any Panel) -> String {
|
|
switch panel.panelType {
|
|
case .terminal:
|
|
return SurfaceKind.terminal
|
|
case .browser:
|
|
return SurfaceKind.browser
|
|
}
|
|
}
|
|
|
|
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 = manualUnreadPanelIds.contains(panelId) || hasUnreadNotification(panelId: panelId)
|
|
if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread {
|
|
return
|
|
}
|
|
bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread)
|
|
}
|
|
|
|
static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool {
|
|
hasUnreadNotification || isManuallyUnread
|
|
}
|
|
|
|
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 }
|
|
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) {
|
|
guard manualUnreadPanelIds.remove(panelId) != nil else { return }
|
|
syncUnreadBadgeStateForPanel(panelId)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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) }
|
|
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 }).union(remoteForwardedPorts)
|
|
listeningPorts = unique.sorted()
|
|
}
|
|
|
|
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() -> [SidebarGitBranchState] {
|
|
SidebarBranchOrdering
|
|
.orderedUniqueBranches(
|
|
orderedPanelIds: sidebarOrderedPanelIds(),
|
|
panelBranches: panelGitBranches,
|
|
fallbackBranch: gitBranch
|
|
)
|
|
.map { SidebarGitBranchState(branch: $0.name, isDirty: $0.isDirty) }
|
|
}
|
|
|
|
func sidebarBranchDirectoryEntriesInDisplayOrder() -> [SidebarBranchOrdering.BranchDirectoryEntry] {
|
|
SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries(
|
|
orderedPanelIds: sidebarOrderedPanelIds(),
|
|
panelBranches: panelGitBranches,
|
|
panelDirectories: panelDirectories,
|
|
defaultDirectory: currentDirectory,
|
|
fallbackBranch: gitBranch
|
|
)
|
|
}
|
|
|
|
var isRemoteWorkspace: Bool {
|
|
remoteConfiguration != nil
|
|
}
|
|
|
|
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
|
|
SidebarBranchOrdering.orderedUniquePullRequests(
|
|
orderedPanelIds: sidebarOrderedPanelIds(),
|
|
panelPullRequests: panelPullRequests,
|
|
fallbackPullRequest: pullRequest
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
var remoteDisplayTarget: String? {
|
|
remoteConfiguration?.displayTarget
|
|
}
|
|
|
|
func remoteStatusPayload() -> [String: Any] {
|
|
var payload: [String: Any] = [
|
|
"enabled": remoteConfiguration != nil,
|
|
"state": remoteConnectionState.rawValue,
|
|
"connected": remoteConnectionState == .connected,
|
|
"daemon": remoteDaemonStatus.payload(),
|
|
"detected_ports": remoteDetectedPorts,
|
|
"forwarded_ports": remoteForwardedPorts,
|
|
"conflicted_ports": remotePortConflicts,
|
|
"detail": remoteConnectionDetail ?? NSNull(),
|
|
]
|
|
if let remoteConfiguration {
|
|
payload["destination"] = remoteConfiguration.destination
|
|
payload["port"] = remoteConfiguration.port ?? NSNull()
|
|
payload["identity_file"] = remoteConfiguration.identityFile ?? NSNull()
|
|
payload["ssh_options"] = remoteConfiguration.sshOptions
|
|
} else {
|
|
payload["destination"] = NSNull()
|
|
payload["port"] = NSNull()
|
|
payload["identity_file"] = NSNull()
|
|
payload["ssh_options"] = []
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) {
|
|
remoteConfiguration = configuration
|
|
remoteDetectedPorts = []
|
|
remoteForwardedPorts = []
|
|
remotePortConflicts = []
|
|
remoteConnectionDetail = nil
|
|
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
|
|
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
|
|
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
|
|
remoteLastErrorFingerprint = nil
|
|
remoteLastDaemonErrorFingerprint = nil
|
|
remoteLastPortConflictFingerprint = nil
|
|
recomputeListeningPorts()
|
|
|
|
remoteSessionController?.stop()
|
|
remoteSessionController = nil
|
|
|
|
guard autoConnect else {
|
|
remoteConnectionState = .disconnected
|
|
return
|
|
}
|
|
|
|
remoteConnectionState = .connecting
|
|
let controller = WorkspaceRemoteSessionController(workspace: self, configuration: configuration)
|
|
remoteSessionController = controller
|
|
controller.start()
|
|
}
|
|
|
|
func reconnectRemoteConnection() {
|
|
guard let configuration = remoteConfiguration else { return }
|
|
configureRemoteConnection(configuration, autoConnect: true)
|
|
}
|
|
|
|
func disconnectRemoteConnection(clearConfiguration: Bool = false) {
|
|
remoteSessionController?.stop()
|
|
remoteSessionController = nil
|
|
remoteDetectedPorts = []
|
|
remoteForwardedPorts = []
|
|
remotePortConflicts = []
|
|
remoteConnectionState = .disconnected
|
|
remoteConnectionDetail = nil
|
|
remoteDaemonStatus = WorkspaceRemoteDaemonStatus()
|
|
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
|
|
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
|
|
remoteLastErrorFingerprint = nil
|
|
remoteLastDaemonErrorFingerprint = nil
|
|
remoteLastPortConflictFingerprint = nil
|
|
if clearConfiguration {
|
|
remoteConfiguration = nil
|
|
}
|
|
recomputeListeningPorts()
|
|
}
|
|
|
|
func teardownRemoteConnection() {
|
|
disconnectRemoteConnection(clearConfiguration: true)
|
|
}
|
|
|
|
fileprivate func applyRemoteConnectionStateUpdate(
|
|
_ state: WorkspaceRemoteConnectionState,
|
|
detail: String?,
|
|
target: String
|
|
) {
|
|
remoteConnectionState = state
|
|
remoteConnectionDetail = detail
|
|
|
|
let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if state == .error, let trimmedDetail, !trimmedDetail.isEmpty {
|
|
statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry(
|
|
key: Self.remoteErrorStatusKey,
|
|
value: "SSH error (\(target)): \(trimmedDetail)",
|
|
icon: "network.slash",
|
|
color: nil,
|
|
timestamp: Date()
|
|
)
|
|
|
|
let fingerprint = "connection:\(trimmedDetail)"
|
|
if remoteLastErrorFingerprint != fingerprint {
|
|
remoteLastErrorFingerprint = fingerprint
|
|
appendSidebarLog(
|
|
message: "SSH error (\(target)): \(trimmedDetail)",
|
|
level: .error,
|
|
source: "remote"
|
|
)
|
|
AppDelegate.shared?.notificationStore?.addNotification(
|
|
tabId: id,
|
|
surfaceId: nil,
|
|
title: "Remote SSH Error",
|
|
subtitle: target,
|
|
body: trimmedDetail
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
if state != .error {
|
|
statusEntries.removeValue(forKey: Self.remoteErrorStatusKey)
|
|
remoteLastErrorFingerprint = nil
|
|
}
|
|
}
|
|
|
|
fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) {
|
|
remoteDaemonStatus = status
|
|
guard status.state == .error else {
|
|
remoteLastDaemonErrorFingerprint = nil
|
|
return
|
|
}
|
|
let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error"
|
|
let fingerprint = "daemon:\(trimmedDetail)"
|
|
guard remoteLastDaemonErrorFingerprint != fingerprint else { return }
|
|
remoteLastDaemonErrorFingerprint = fingerprint
|
|
appendSidebarLog(
|
|
message: "Remote daemon error (\(target)): \(trimmedDetail)",
|
|
level: .error,
|
|
source: "remote-daemon"
|
|
)
|
|
}
|
|
|
|
fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) {
|
|
remoteDetectedPorts = detected
|
|
remoteForwardedPorts = forwarded
|
|
remotePortConflicts = conflicts
|
|
recomputeListeningPorts()
|
|
|
|
if conflicts.isEmpty {
|
|
statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey)
|
|
remoteLastPortConflictFingerprint = nil
|
|
return
|
|
}
|
|
|
|
let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ")
|
|
statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry(
|
|
key: Self.remotePortConflictStatusKey,
|
|
value: "SSH port conflicts (\(target)): \(conflictsList)",
|
|
icon: "exclamationmark.triangle.fill",
|
|
color: nil,
|
|
timestamp: Date()
|
|
)
|
|
|
|
let fingerprint = conflicts.map(String.init).joined(separator: ",")
|
|
guard remoteLastPortConflictFingerprint != fingerprint else { return }
|
|
remoteLastPortConflictFingerprint = fingerprint
|
|
appendSidebarLog(
|
|
message: "Port conflicts while forwarding \(target): \(conflictsList)",
|
|
level: .warning,
|
|
source: "remote-forward"
|
|
)
|
|
}
|
|
|
|
private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) {
|
|
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date()))
|
|
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50
|
|
let limit = max(1, min(500, configuredLimit))
|
|
if logEntries.count > limit {
|
|
logEntries.removeFirst(logEntries.count - limit)
|
|
}
|
|
}
|
|
|
|
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 else { return }
|
|
guard seen.insert(panel.id).inserted else { return }
|
|
candidates.append(panel)
|
|
}
|
|
|
|
if let preferredPanelId, let preferredTerminal = terminalPanel(for: preferredPanelId) {
|
|
appendCandidate(preferredTerminal)
|
|
}
|
|
|
|
appendCandidate(focusedTerminalPanel)
|
|
|
|
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
|
|
}
|
|
|
|
func terminalPanelForConfigInheritance(
|
|
preferredPanelId: UUID? = nil,
|
|
inPane preferredPaneId: PaneID? = nil
|
|
) -> TerminalPanel? {
|
|
terminalPanelConfigInheritanceCandidates(
|
|
preferredPanelId: preferredPanelId,
|
|
inPane: preferredPaneId
|
|
).first
|
|
}
|
|
|
|
// MARK: - Panel Operations
|
|
|
|
/// Create a new split with a terminal panel
|
|
@discardableResult
|
|
func newTerminalSplit(
|
|
from panelId: UUID,
|
|
orientation: SplitOrientation,
|
|
insertFirst: Bool = false,
|
|
focus: Bool = true
|
|
) -> TerminalPanel? {
|
|
// Get inherited config from the source terminal when possible.
|
|
// If the split is initiated from a non-terminal panel (for example browser),
|
|
// fall back to any terminal in the workspace.
|
|
let inheritedConfig: ghostty_surface_config_s? = {
|
|
if let sourceTerminal = terminalPanel(for: panelId),
|
|
let existing = sourceTerminal.surface.surface {
|
|
return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
|
}
|
|
if let fallbackSurface = panels.values
|
|
.compactMap({ ($0 as? TerminalPanel)?.surface.surface })
|
|
.first {
|
|
return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
// Find the pane containing the source panel
|
|
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
|
|
var sourcePaneId: PaneID?
|
|
for paneId in bonsplitController.allPaneIds {
|
|
let tabs = bonsplitController.tabs(inPane: paneId)
|
|
if tabs.contains(where: { $0.id == sourceTabId }) {
|
|
sourcePaneId = paneId
|
|
break
|
|
}
|
|
}
|
|
|
|
guard let paneId = sourcePaneId else { return nil }
|
|
|
|
// Create the new terminal panel.
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
|
|
// 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)
|
|
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)
|
|
|
|
// Get an existing terminal panel to inherit config from
|
|
let inheritedConfig: ghostty_surface_config_s? = {
|
|
for panel in panels.values {
|
|
if let terminalPanel = panel as? TerminalPanel,
|
|
let surface = terminalPanel.surface.surface {
|
|
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
|
}
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
// Create new terminal panel
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
workingDirectory: workingDirectory,
|
|
portOrdinal: portOrdinal,
|
|
initialEnvironmentOverrides: startupEnvironment
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
|
|
/// Close a panel.
|
|
/// Returns true when a bonsplit tab close request was issued.
|
|
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
|
|
if let tabId = surfaceIdFromPanelId(panelId) {
|
|
if force {
|
|
forceCloseTabIds.insert(tabId)
|
|
}
|
|
// Close the tab in bonsplit (this triggers delegate callback)
|
|
return bonsplitController.closeTab(tabId)
|
|
}
|
|
|
|
// Mapping can transiently drift during split-tree mutations. If the target panel is
|
|
// currently focused (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)"
|
|
)
|
|
#endif
|
|
return closed
|
|
}
|
|
|
|
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 }
|
|
|
|
detachingTabIds.insert(tabId)
|
|
forceCloseTabIds.insert(tabId)
|
|
guard bonsplitController.closeTab(tabId) else {
|
|
detachingTabIds.remove(tabId)
|
|
pendingDetachedSurfaces.removeValue(forKey: tabId)
|
|
forceCloseTabIds.remove(tabId)
|
|
return nil
|
|
}
|
|
|
|
return pendingDetachedSurfaces.removeValue(forKey: tabId)
|
|
}
|
|
|
|
@discardableResult
|
|
func attachDetachedSurface(
|
|
_ detached: DetachedSurfaceTransfer,
|
|
inPane paneId: PaneID,
|
|
atIndex index: Int? = nil,
|
|
focus: Bool = true
|
|
) -> UUID? {
|
|
guard bonsplitController.allPaneIds.contains(paneId) else { return nil }
|
|
guard panels[detached.panelId] == nil else { return nil }
|
|
|
|
panels[detached.panelId] = detached.panel
|
|
if let terminalPanel = detached.panel as? TerminalPanel {
|
|
terminalPanel.updateWorkspaceId(id)
|
|
} else if let browserPanel = detached.panel as? BrowserPanel {
|
|
browserPanel.updateWorkspaceId(id)
|
|
installBrowserPanelSubscription(browserPanel)
|
|
}
|
|
|
|
if let directory = detached.directory {
|
|
panelDirectories[detached.panelId] = directory
|
|
}
|
|
if let cachedTitle = detached.cachedTitle {
|
|
panelTitles[detached.panelId] = cachedTitle
|
|
}
|
|
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)
|
|
} else {
|
|
manualUnreadPanelIds.remove(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)
|
|
panelSubscriptions.removeValue(forKey: detached.panelId)
|
|
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()
|
|
|
|
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
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
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 markExplicitFocusIntent(on panelId: UUID) {
|
|
guard let pending = pendingNonFocusSplitFocusReassert,
|
|
pending.splitPanelId == panelId else {
|
|
return
|
|
}
|
|
pendingNonFocusSplitFocusReassert = nil
|
|
}
|
|
|
|
func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
|
|
markExplicitFocusIntent(on: panelId)
|
|
#if DEBUG
|
|
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
|
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)")
|
|
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
|
|
#endif
|
|
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
|
|
let currentlyFocusedPanelId = focusedPanelId
|
|
|
|
// Capture the currently focused terminal view so we can explicitly move AppKit first
|
|
// responder when focusing another terminal (helps avoid "highlighted but typing goes to
|
|
// another pane" after heavy split/tab mutations).
|
|
// 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
|
|
}()
|
|
|
|
if let targetPaneId, !selectionAlreadyConverged {
|
|
bonsplitController.focusPane(targetPaneId)
|
|
}
|
|
|
|
if !selectionAlreadyConverged {
|
|
bonsplitController.selectTab(tabId)
|
|
}
|
|
|
|
// Also focus the underlying panel
|
|
if let panel = panels[panelId] {
|
|
if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged {
|
|
panel.focus()
|
|
}
|
|
|
|
if let terminalPanel = panel as? TerminalPanel {
|
|
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
|
|
// (becomeFirstResponder -> onFocus -> focusPanel).
|
|
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
|
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
|
|
}
|
|
}
|
|
}
|
|
if let targetPaneId {
|
|
applyTabSelection(tabId: tabId, inPane: targetPaneId)
|
|
}
|
|
}
|
|
|
|
func moveFocus(direction: NavigationDirection) {
|
|
// Unfocus the currently-focused panel before navigating.
|
|
if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] {
|
|
prev.unfocus()
|
|
}
|
|
|
|
bonsplitController.navigateFocus(direction: direction)
|
|
|
|
// Always reconcile selection/focus after navigation so AppKit first-responder and
|
|
// bonsplit's focused pane stay aligned, even through split tree mutations.
|
|
if let paneId = bonsplitController.focusedPaneId,
|
|
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
|
|
applyTabSelection(tabId: tabId, inPane: paneId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Surface Navigation
|
|
|
|
/// Select the next surface in the currently focused pane
|
|
func selectNextSurface() {
|
|
bonsplitController.selectNextTab()
|
|
|
|
if let paneId = bonsplitController.focusedPaneId,
|
|
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
|
|
applyTabSelection(tabId: tabId, inPane: paneId)
|
|
}
|
|
}
|
|
|
|
/// Select the previous surface in the currently focused pane
|
|
func selectPreviousSurface() {
|
|
bonsplitController.selectPreviousTab()
|
|
|
|
if let paneId = bonsplitController.focusedPaneId,
|
|
let tabId = bonsplitController.selectedTab(inPane: paneId)?.id {
|
|
applyTabSelection(tabId: tabId, inPane: paneId)
|
|
}
|
|
}
|
|
|
|
/// Select a surface by index in the currently focused pane
|
|
func selectSurface(at index: Int) {
|
|
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
|
|
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
|
|
guard index >= 0 && index < tabs.count else { return }
|
|
bonsplitController.selectTab(tabs[index].id)
|
|
|
|
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
|
|
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
|
|
}
|
|
}
|
|
|
|
/// Select the last surface in the currently focused pane
|
|
func selectLastSurface() {
|
|
guard let focusedPaneId = bonsplitController.focusedPaneId else { return }
|
|
let tabs = bonsplitController.tabs(inPane: focusedPaneId)
|
|
guard let last = tabs.last else { return }
|
|
bonsplitController.selectTab(last.id)
|
|
|
|
if let tabId = bonsplitController.selectedTab(inPane: focusedPaneId)?.id {
|
|
applyTabSelection(tabId: tabId, inPane: focusedPaneId)
|
|
}
|
|
}
|
|
|
|
/// Create a new terminal surface in the currently focused pane
|
|
@discardableResult
|
|
func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? {
|
|
guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil }
|
|
return newTerminalSurface(inPane: focusedPaneId, focus: focus)
|
|
}
|
|
|
|
// MARK: - Flash/Notification Support
|
|
|
|
func triggerFocusFlash(panelId: UUID) {
|
|
if let terminalPanel = terminalPanel(for: panelId) {
|
|
terminalPanel.triggerFlash()
|
|
return
|
|
}
|
|
if let browserPanel = browserPanel(for: panelId) {
|
|
browserPanel.triggerFlash()
|
|
return
|
|
}
|
|
}
|
|
|
|
func triggerNotificationFocusFlash(
|
|
panelId: UUID,
|
|
requiresSplit: Bool = false,
|
|
shouldFocus: Bool = true
|
|
) {
|
|
guard let terminalPanel = terminalPanel(for: panelId) else { return }
|
|
if shouldFocus {
|
|
focusPanel(panelId)
|
|
}
|
|
let isSplit = bonsplitController.allPaneIds.count > 1 || panels.count > 1
|
|
if requiresSplit && !isSplit {
|
|
return
|
|
}
|
|
terminalPanel.triggerFlash()
|
|
}
|
|
|
|
func triggerDebugFlash(panelId: UUID) {
|
|
triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true)
|
|
}
|
|
|
|
// MARK: - 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 newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_TAB,
|
|
configTemplate: nil,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
|
|
// 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 !detachingTabIds.isEmpty {
|
|
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 scheduleTerminalGeometryReconcile() {
|
|
guard !geometryReconcileScheduled else { return }
|
|
geometryReconcileScheduled = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.geometryReconcileScheduled = false
|
|
|
|
for panel in self.panels.values {
|
|
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
|
terminalPanel.hostedView.reconcileGeometryNow()
|
|
terminalPanel.surface.forceRefresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = "Rename Tab"
|
|
alert.informativeText = "Enter a custom name for this tab."
|
|
let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle
|
|
let input = NSTextField(string: currentTitle)
|
|
input.placeholderString = "Tab name"
|
|
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: "Rename")
|
|
alert.addButton(withTitle: "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)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - BonsplitDelegate
|
|
|
|
extension Workspace: BonsplitDelegate {
|
|
@MainActor
|
|
private func confirmClosePanel(for tabId: TabID) async -> Bool {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Close tab?"
|
|
alert.informativeText = "This will close the current tab."
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: "Close")
|
|
alert.addButton(withTitle: "Cancel")
|
|
|
|
// Prefer a sheet if we can find a window, otherwise fall back to modal.
|
|
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
|
|
return await withCheckedContinuation { continuation in
|
|
alert.beginSheetModal(for: window) { response in
|
|
continuation.resume(returning: response == .alertFirstButtonReturn)
|
|
}
|
|
}
|
|
}
|
|
|
|
return alert.runModal() == .alertFirstButtonReturn
|
|
}
|
|
|
|
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
|
|
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
|
|
private func applyTabSelection(tabId: TabID, inPane pane: PaneID) {
|
|
pendingTabSelection = (tabId: tabId, pane: pane)
|
|
guard !isApplyingTabSelection else { return }
|
|
isApplyingTabSelection = true
|
|
defer {
|
|
isApplyingTabSelection = false
|
|
pendingTabSelection = nil
|
|
}
|
|
|
|
var iterations = 0
|
|
while let request = pendingTabSelection {
|
|
pendingTabSelection = nil
|
|
iterations += 1
|
|
if iterations > 8 { break }
|
|
applyTabSelectionNow(tabId: request.tabId, inPane: request.pane)
|
|
}
|
|
}
|
|
|
|
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
|
|
if bonsplitController.allPaneIds.contains(pane) {
|
|
if bonsplitController.focusedPaneId != pane {
|
|
bonsplitController.focusPane(pane)
|
|
}
|
|
if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }),
|
|
bonsplitController.selectedTab(inPane: pane)?.id != tabId {
|
|
bonsplitController.selectTab(tabId)
|
|
}
|
|
}
|
|
|
|
let focusedPane: PaneID
|
|
let selectedTabId: TabID
|
|
if let currentPane = bonsplitController.focusedPaneId,
|
|
let currentTabId = bonsplitController.selectedTab(inPane: currentPane)?.id {
|
|
focusedPane = currentPane
|
|
selectedTabId = currentTabId
|
|
} else if bonsplitController.tabs(inPane: pane).contains(where: { $0.id == tabId }) {
|
|
focusedPane = pane
|
|
selectedTabId = tabId
|
|
bonsplitController.focusPane(focusedPane)
|
|
bonsplitController.selectTab(selectedTabId)
|
|
} else {
|
|
return
|
|
}
|
|
|
|
// Focus the selected panel
|
|
guard let panelId = panelIdFromSurfaceId(selectedTabId),
|
|
let panel = panels[panelId] else {
|
|
return
|
|
}
|
|
syncPinnedStateForTab(selectedTabId, panelId: panelId)
|
|
syncUnreadBadgeStateForPanel(panelId)
|
|
|
|
// Unfocus all other panels
|
|
for (id, p) in panels where id != panelId {
|
|
p.unfocus()
|
|
}
|
|
|
|
panel.focus()
|
|
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
|
|
}
|
|
refreshFocusedGitBranchState()
|
|
|
|
// Post notification
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusSurface,
|
|
object: nil,
|
|
userInfo: [
|
|
GhosttyNotificationKey.tabId: self.id,
|
|
GhosttyNotificationKey.surfaceId: panelId
|
|
]
|
|
)
|
|
}
|
|
|
|
private func refreshFocusedGitBranchState() {
|
|
if let focusedPanelId {
|
|
gitBranch = panelGitBranches[focusedPanelId]
|
|
pullRequest = panelPullRequests[focusedPanelId]
|
|
} else {
|
|
gitBranch = nil
|
|
pullRequest = 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)
|
|
|
|
// Clean up our panel
|
|
guard let panelId = panelIdFromSurfaceId(tabId) else {
|
|
#if DEBUG
|
|
NSLog("[Workspace] didCloseTab: no panelId for tabId")
|
|
#endif
|
|
refreshFocusedGitBranchState()
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
return
|
|
}
|
|
|
|
#if DEBUG
|
|
NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)")
|
|
#endif
|
|
|
|
let isDetaching = detachingTabIds.remove(tabId) != nil
|
|
let panel = panels[panelId]
|
|
|
|
if isDetaching, let panel {
|
|
let browserPanel = panel as? BrowserPanel
|
|
let cachedTitle = panelTitles[panelId] ?? panel.displayTitle
|
|
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
|
|
panelId: panelId,
|
|
panel: panel,
|
|
title: resolvedPanelTitle(panelId: panelId, fallback: cachedTitle),
|
|
icon: panel.displayIcon,
|
|
iconImageData: browserPanel?.faviconPNGData,
|
|
kind: surfaceKind(for: panel),
|
|
isLoading: browserPanel?.isLoading ?? false,
|
|
isPinned: pinnedPanelIds.contains(panelId),
|
|
directory: panelDirectories[panelId],
|
|
cachedTitle: panelTitles[panelId],
|
|
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)
|
|
panelPullRequests.removeValue(forKey: panelId)
|
|
panelTitles.removeValue(forKey: panelId)
|
|
panelCustomTitles.removeValue(forKey: panelId)
|
|
pinnedPanelIds.remove(panelId)
|
|
manualUnreadPanelIds.remove(panelId)
|
|
panelGitBranches.removeValue(forKey: panelId)
|
|
panelSubscriptions.removeValue(forKey: panelId)
|
|
surfaceTTYNames.removeValue(forKey: panelId)
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
|
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
|
|
|
// Keep the workspace invariant: always retain at least one real panel.
|
|
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
|
|
if panels.isEmpty {
|
|
if isDetaching {
|
|
gitBranch = nil
|
|
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)
|
|
}
|
|
refreshFocusedGitBranchState()
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
return
|
|
}
|
|
|
|
if let selectTabId,
|
|
bonsplitController.allPaneIds.contains(pane),
|
|
bonsplitController.tabs(inPane: pane).contains(where: { $0.id == selectTabId }),
|
|
bonsplitController.focusedPaneId == pane {
|
|
// Keep selection/focus convergence in the same close transaction to avoid a transient
|
|
// frame where the pane has no selected content.
|
|
bonsplitController.selectTab(selectTabId)
|
|
applyTabSelection(tabId: selectTabId, inPane: pane)
|
|
}
|
|
|
|
if bonsplitController.allPaneIds.contains(pane) {
|
|
normalizePinnedTabs(in: pane)
|
|
}
|
|
refreshFocusedGitBranchState()
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) {
|
|
applyTabSelection(tabId: tab.id, inPane: pane)
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) {
|
|
#if DEBUG
|
|
let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown"
|
|
dlog(
|
|
"split.moveTab 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)"
|
|
)
|
|
#endif
|
|
applyTabSelection(tabId: tab.id, inPane: destination)
|
|
normalizePinnedTabs(in: source)
|
|
normalizePinnedTabs(in: destination)
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) {
|
|
// When a pane is focused, focus its selected tab's panel
|
|
guard let tab = controller.selectedTab(inPane: pane) else { return }
|
|
#if DEBUG
|
|
FocusLogStore.shared.append(
|
|
"Workspace.didFocusPane paneId=\(pane.id.uuidString) tabId=\(tab.id) focusedPane=\(controller.focusedPaneId?.id.uuidString ?? "nil")"
|
|
)
|
|
#endif
|
|
applyTabSelection(tabId: tab.id, inPane: pane)
|
|
|
|
// Apply window background for terminal
|
|
if let panelId = panelIdFromSurfaceId(tab.id),
|
|
let terminalPanel = panels[panelId] as? TerminalPanel {
|
|
terminalPanel.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
|
|
_ = paneId
|
|
let liveTabIds: Set<TabID> = Set(
|
|
controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) }
|
|
)
|
|
let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) }
|
|
for (staleTabId, stalePanelId) in staleMappings {
|
|
panels[stalePanelId]?.close()
|
|
panels.removeValue(forKey: stalePanelId)
|
|
surfaceIdToPanelId.removeValue(forKey: staleTabId)
|
|
panelDirectories.removeValue(forKey: stalePanelId)
|
|
panelTitles.removeValue(forKey: stalePanelId)
|
|
panelCustomTitles.removeValue(forKey: stalePanelId)
|
|
pinnedPanelIds.remove(stalePanelId)
|
|
manualUnreadPanelIds.remove(stalePanelId)
|
|
panelGitBranches.removeValue(forKey: stalePanelId)
|
|
panelPullRequests.removeValue(forKey: stalePanelId)
|
|
panelSubscriptions.removeValue(forKey: stalePanelId)
|
|
surfaceTTYNames.removeValue(forKey: stalePanelId)
|
|
surfaceListeningPorts.removeValue(forKey: stalePanelId)
|
|
restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId)
|
|
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId)
|
|
}
|
|
if !staleMappings.isEmpty {
|
|
recomputeListeningPorts()
|
|
}
|
|
|
|
refreshFocusedGitBranchState()
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool {
|
|
// Check if any panel in this pane needs close confirmation
|
|
let tabs = controller.tabs(inPane: pane)
|
|
for tab in tabs {
|
|
if forceCloseTabIds.contains(tab.id) { continue }
|
|
if let panelId = panelIdFromSurfaceId(tab.id),
|
|
let terminalPanel = terminalPanel(for: panelId),
|
|
terminalPanel.needsConfirmClose() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didSplitPane originalPane: PaneID, newPane: PaneID, orientation: SplitOrientation) {
|
|
#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: ghostty_surface_config_s? = {
|
|
for panel in panels.values {
|
|
if let terminalPanel = panel as? TerminalPanel,
|
|
let surface = terminalPanel.surface.surface {
|
|
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
|
}
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
let replacementPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[replacementPanel.id] = replacementPanel
|
|
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
|
|
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
|
|
}
|
|
|
|
// Get the focused terminal in the original pane to inherit config from
|
|
guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id,
|
|
let sourcePanelId = panelIdFromSurfaceId(sourceTabId),
|
|
let sourcePanel = terminalPanel(for: sourcePanelId) else { return }
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"split.didSplit.autoCreate pane=\(newPane.id.uuidString.prefix(5)) " +
|
|
"fromPane=\(originalPane.id.uuidString.prefix(5)) sourcePanel=\(sourcePanelId.uuidString.prefix(5))"
|
|
)
|
|
#endif
|
|
|
|
let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface {
|
|
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
let newPanel = TerminalPanel(
|
|
workspaceId: id,
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: inheritedConfig,
|
|
portOrdinal: portOrdinal
|
|
)
|
|
panels[newPanel.id] = newPanel
|
|
panelTitles[newPanel.id] = newPanel.displayTitle
|
|
|
|
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)
|
|
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:
|
|
// TODO: Wire this to a move target picker.
|
|
return
|
|
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)
|
|
AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId)
|
|
case .markAsUnread:
|
|
guard let panelId = panelIdFromSurfaceId(tab.id) else { return }
|
|
markPanelUnread(panelId)
|
|
}
|
|
}
|
|
|
|
func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) {
|
|
_ = snapshot
|
|
scheduleTerminalGeometryReconcile()
|
|
scheduleFocusReconcile()
|
|
}
|
|
|
|
// No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.
|
|
}
|