* Sidebar ports on own line, wider sidebar, CMUX_PORT env vars - Move listening ports to dedicated sidebar row (removed from branch/directory line) - Allow sidebar to resize up to 2/3 of screen width (was capped at 360px) - Add CMUX_PORT, CMUX_PORT_END, CMUX_PORT_RANGE env vars per workspace - Each workspace gets a dedicated port range (default: base 9100, range 10) - Add settings UI for port base and range size - Add portOrdinal to Workspace, monotonic counter in TabManager Closes #129 * Make port ordinal counter static to avoid overlap across windows Each window creates its own TabManager, so a per-instance counter would reset and reuse port ranges. Making it static ensures unique ranges across all windows. * Fix portOrdinal race: pass through Workspace init instead of setting after The first TerminalPanel is created inside Workspace.init, so setting portOrdinal after init returns meant the initial terminal always got ordinal 0. Pass portOrdinal as an init parameter and set it before the TerminalPanel is created. * Fix P2/P3: snapshot port settings at surface creation, use window screen for sidebar cap P2: Port base/range are now snapshotted on TerminalSurface when the panel is created, so changing settings mid-session won't cause inconsistent CMUX_PORT values across terminals in the same workspace. P3: Sidebar max width now uses NSApp.keyWindow?.screen instead of NSScreen.main, so multi-monitor setups get the correct 2/3 cap for the display the window is actually on. * Fix P1: snapshot port base/range once per app session, not per panel Port base and range size are now static properties on TerminalSurface, initialized once from UserDefaults at first access. This prevents overlapping port ranges across workspaces when settings are changed mid-session (e.g., workspace 1 with range=10 at 9110-9119, then range changed to 5, workspace 2 would overlap at 9110).
171 lines
5.4 KiB
Swift
171 lines
5.4 KiB
Swift
import Foundation
|
|
import Combine
|
|
import AppKit
|
|
|
|
/// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol.
|
|
/// This allows TerminalSurface to be used within the bonsplit-based layout system.
|
|
@MainActor
|
|
final class TerminalPanel: Panel, ObservableObject {
|
|
let id: UUID
|
|
let panelType: PanelType = .terminal
|
|
|
|
/// The underlying terminal surface
|
|
let surface: TerminalSurface
|
|
|
|
/// The workspace ID this panel belongs to
|
|
private(set) var workspaceId: UUID
|
|
|
|
/// Published title from the terminal process
|
|
@Published private(set) var title: String = "Terminal"
|
|
|
|
/// Published directory from the terminal
|
|
@Published private(set) var directory: String = ""
|
|
|
|
/// Search state for find functionality
|
|
@Published var searchState: TerminalSurface.SearchState? {
|
|
didSet {
|
|
surface.searchState = searchState
|
|
}
|
|
}
|
|
|
|
/// Bump this token to force SwiftUI to call `updateNSView` on `GhosttyTerminalView`,
|
|
/// which re-attaches the hosted view after bonsplit close/reparent operations.
|
|
///
|
|
/// Without this, certain pane-close sequences can leave terminal views detached
|
|
/// (hostedView.window == nil) until the user switches workspaces.
|
|
@Published var viewReattachToken: UInt64 = 0
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
var displayTitle: String {
|
|
title.isEmpty ? "Terminal" : title
|
|
}
|
|
|
|
var displayIcon: String? {
|
|
"terminal.fill"
|
|
}
|
|
|
|
var isDirty: Bool {
|
|
// Bonsplit's "dirty" indicator is a very small dot in the tab strip.
|
|
//
|
|
// For terminals, `ghostty_surface_needs_confirm_quit` is driven by shell integration
|
|
// heuristics and can be transiently (or permanently) wrong, which results in a dot
|
|
// showing on every new terminal. That reads as a notification/alert and is misleading.
|
|
//
|
|
// We still honor `needsConfirmClose()` when actually closing a panel; we just don't
|
|
// surface it as a tab-level dirty indicator.
|
|
false
|
|
}
|
|
|
|
/// The hosted NSView for embedding in SwiftUI
|
|
var hostedView: GhosttySurfaceScrollView {
|
|
surface.hostedView
|
|
}
|
|
|
|
init(workspaceId: UUID, surface: TerminalSurface) {
|
|
self.id = surface.id
|
|
self.workspaceId = workspaceId
|
|
self.surface = surface
|
|
|
|
// Subscribe to surface's search state changes
|
|
surface.$searchState
|
|
.sink { [weak self] state in
|
|
if self?.searchState !== state {
|
|
self?.searchState = state
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
/// Create a new terminal panel with a fresh surface
|
|
convenience init(
|
|
workspaceId: UUID,
|
|
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: ghostty_surface_config_s? = nil,
|
|
workingDirectory: String? = nil,
|
|
portOrdinal: Int = 0
|
|
) {
|
|
let surface = TerminalSurface(
|
|
tabId: workspaceId,
|
|
context: context,
|
|
configTemplate: configTemplate,
|
|
workingDirectory: workingDirectory
|
|
)
|
|
surface.portOrdinal = portOrdinal
|
|
self.init(workspaceId: workspaceId, surface: surface)
|
|
}
|
|
|
|
func updateTitle(_ newTitle: String) {
|
|
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty && title != trimmed {
|
|
title = trimmed
|
|
}
|
|
}
|
|
|
|
func updateDirectory(_ newDirectory: String) {
|
|
let trimmed = newDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty && directory != trimmed {
|
|
directory = trimmed
|
|
}
|
|
}
|
|
|
|
func updateWorkspaceId(_ newWorkspaceId: UUID) {
|
|
workspaceId = newWorkspaceId
|
|
surface.updateWorkspaceId(newWorkspaceId)
|
|
}
|
|
|
|
func focus() {
|
|
surface.setFocus(true)
|
|
// `unfocus()` force-disables active state to stop stale retries from stealing focus.
|
|
// Re-enable it immediately for explicit focus requests (socket/UI) so ensureFocus can run.
|
|
hostedView.setActive(true)
|
|
hostedView.ensureFocus(for: workspaceId, surfaceId: id)
|
|
}
|
|
|
|
func unfocus() {
|
|
surface.setFocus(false)
|
|
// Cancel any pending focus work items so an inactive terminal can't steal first responder
|
|
// back from another surface (notably WKWebView) during rapid focus changes in tests.
|
|
//
|
|
// Also flip the hosted view's active state immediately: SwiftUI focus propagation can lag
|
|
// by a runloop tick, and `requestFocus` retries that are already executing can otherwise
|
|
// schedule new work items that fire after we navigate away.
|
|
hostedView.setActive(false)
|
|
}
|
|
|
|
func close() {
|
|
// The surface will be cleaned up by its deinit
|
|
// Just unfocus before closing
|
|
unfocus()
|
|
}
|
|
|
|
func requestViewReattach() {
|
|
viewReattachToken &+= 1
|
|
}
|
|
|
|
// MARK: - Terminal-specific methods
|
|
|
|
func sendText(_ text: String) {
|
|
surface.sendText(text)
|
|
}
|
|
|
|
func performBindingAction(_ action: String) -> Bool {
|
|
surface.performBindingAction(action)
|
|
}
|
|
|
|
func hasSelection() -> Bool {
|
|
surface.hasSelection()
|
|
}
|
|
|
|
func needsConfirmClose() -> Bool {
|
|
surface.needsConfirmClose()
|
|
}
|
|
|
|
func triggerFlash() {
|
|
hostedView.triggerFlash()
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surface.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|