* Pre-launch app for browser UI test on headless CI runners
XCUIApplication.launch() blocks ~60s then fails on headless WarpBuild
runners because foreground activation requires a GUI login session.
Apply the same pre-launch strategy used for the display resolution test:
- CI shell launches the app with env vars before running xcodebuild
- Test detects pre-launched app via manifest, uses activate() instead of
launch() to avoid killing and relaunching the app
- Falls back to clicking the window for focus via accessibility framework
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert "Pre-launch app for browser UI test on headless CI runners"
This reverts commit a540e2fd99aaa1395b91a8d50caa797cdd7551b8.
* feat: cmux.json for custom commands
* tests: add cmux json tests
* fix: pr review feedback: validation, translations, input handling, and palette improvements
- Fix Danish ("Overfladedef inition") and Norwegian ("rotmapp") translation typos
- Add empty-string check for baseCwd fallback in command palette handlers
- Coalesce \r\n into single Return keypress in sendInput
- Redact command text from timeout log to prevent secret leakage
- Add decode-time validation: reject hybrid/empty commands, ambiguous layout
nodes, wrong split children count, and empty pane surfaces
- Namespace custom command IDs with "cmux.config.command." prefix
- Forward command description to palette subtitle when available
- Update tests for new validation rules and ID prefix
* fix: address PR review feedback — per-window config isolation, blank validation, ancestor walk,
palette sanitization
* fix: fallback to current dir cmux.json watching if no any cmux.json found in full acesor walk
* ci: trigger CI for fork PR
* Add directory trust for cmux.json command confirmation
The confirm dialog now shows the actual command text and has an "Always
trust commands from this folder" checkbox. When checked, future confirm
commands from that directory skip the dialog.
Trust is scoped to the git repo root if the cmux.json is inside a repo,
so trusting once covers all subdirectories. Non-git directories are
trusted by exact path. Global config is always trusted.
Trusted directories are persisted in ~/Library/Application Support/cmux/
trusted-directories.json.
* Add trusted directories section to Settings
Shows all trusted directories with per-directory revoke buttons and a
Clear All option. Placed in a "Custom Commands" section between
Automation and Browser in Settings.
* Replace trusted directories list with editable textarea
One path per line, with a Save button that activates on changes.
Users can add, remove, or edit paths directly.
* Auto-save trusted directories on edit, remove Save button
Matches the behavior of other textarea settings (browser host
whitelist, external URL patterns) which auto-save via @AppStorage.
* Sanitize command text in confirm dialog against BiDi attacks
Strip zero-width and BiDi override characters from the command preview
so the dialog shows exactly what will be executed.
---------
Co-authored-by: austinpower1258 <austinwang115@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
276 lines
9.6 KiB
Swift
276 lines
9.6 KiB
Swift
import Foundation
|
|
import Combine
|
|
import AppKit
|
|
import Bonsplit
|
|
|
|
/// 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 = ""
|
|
|
|
@Published private(set) var tmuxLayoutReport: TmuxPaneLayoutReport?
|
|
|
|
/// 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
|
|
|
|
var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)?
|
|
|
|
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
|
|
}
|
|
|
|
var requestedWorkingDirectory: String? {
|
|
surface.requestedWorkingDirectory
|
|
}
|
|
|
|
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,
|
|
initialCommand: String? = nil,
|
|
initialEnvironmentOverrides: [String: String] = [:],
|
|
additionalEnvironment: [String: String] = [:]
|
|
) {
|
|
let surface = TerminalSurface(
|
|
tabId: workspaceId,
|
|
context: context,
|
|
configTemplate: configTemplate,
|
|
workingDirectory: workingDirectory,
|
|
initialCommand: initialCommand,
|
|
initialEnvironmentOverrides: initialEnvironmentOverrides,
|
|
additionalEnvironment: additionalEnvironment
|
|
)
|
|
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 updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) {
|
|
guard tmuxLayoutReport != report else { return }
|
|
tmuxLayoutReport = report
|
|
}
|
|
|
|
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
|
|
// Detach from the window portal on real close so stale hosted views
|
|
// cannot remain above browser panes after split close.
|
|
surface.beginPortalCloseLifecycle(reason: "panel.close")
|
|
#if DEBUG
|
|
let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height)
|
|
let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height)
|
|
dlog(
|
|
"surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " +
|
|
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
|
"hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
|
|
)
|
|
#endif
|
|
unfocus()
|
|
hostedView.setVisibleInUI(false)
|
|
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.panel.close.end panel=\(id.uuidString.prefix(5)) " +
|
|
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
|
"hidden=\(hostedView.isHidden ? 1 : 0)"
|
|
)
|
|
#endif
|
|
surface.teardownSurface()
|
|
}
|
|
|
|
func requestViewReattach() {
|
|
viewReattachToken &+= 1
|
|
}
|
|
|
|
// MARK: - Terminal-specific methods
|
|
|
|
func sendText(_ text: String) {
|
|
surface.sendText(text)
|
|
}
|
|
|
|
func sendInput(_ text: String) {
|
|
surface.sendInput(text)
|
|
}
|
|
|
|
func performBindingAction(_ action: String) -> Bool {
|
|
surface.performBindingAction(action)
|
|
}
|
|
|
|
func hasSelection() -> Bool {
|
|
surface.hasSelection()
|
|
}
|
|
|
|
func needsConfirmClose() -> Bool {
|
|
surface.needsConfirmClose()
|
|
}
|
|
|
|
func shouldPersistScrollbackForSessionSnapshot() -> Bool {
|
|
// Session restore only replays terminal output into a fresh shell. If Ghostty
|
|
// says we are not safely at a prompt, replaying that state later is misleading.
|
|
!surface.needsConfirmClose()
|
|
}
|
|
|
|
func triggerFlash(reason: WorkspaceAttentionFlashReason) {
|
|
guard NotificationPaneFlashSettings.isEnabled() else { return }
|
|
|
|
switch TmuxOverlayExperimentSettings.target() {
|
|
case .bonsplitPane:
|
|
if let onRequestWorkspacePaneFlash {
|
|
onRequestWorkspacePaneFlash(reason)
|
|
return
|
|
}
|
|
hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason))
|
|
case .surface, .tmuxActivePane:
|
|
hostedView.triggerFlash(style: GhosttySurfaceScrollView.flashStyle(for: reason))
|
|
}
|
|
}
|
|
|
|
func triggerNotificationDismissFlash() {
|
|
triggerFlash(reason: .notificationDismiss)
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surface.applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
|
.terminal(hostedView.capturePanelFocusIntent(in: window))
|
|
}
|
|
|
|
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
|
.terminal(hostedView.preferredPanelFocusIntentForActivation())
|
|
}
|
|
|
|
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
|
guard case .terminal(let target) = intent else { return }
|
|
hostedView.preparePanelFocusIntentForActivation(target)
|
|
}
|
|
|
|
@discardableResult
|
|
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
|
switch intent {
|
|
case .panel:
|
|
focus()
|
|
return true
|
|
case .terminal(let target):
|
|
return hostedView.restorePanelFocusIntent(target)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
|
_ = window
|
|
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
|
|
return .terminal(intent)
|
|
}
|
|
|
|
@discardableResult
|
|
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
|
guard case .terminal(let target) = intent else { return false }
|
|
return hostedView.yieldPanelFocusIntent(target, in: window)
|
|
}
|
|
}
|