Merge branch 'main' into issue-151-ssh-remote-port-proxying

# Conflicts:
#	CLI/cmux.swift
#	Sources/ContentView.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/Panels/BrowserPanel.swift
#	Sources/Panels/BrowserPanelView.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
#	Sources/WorkspaceContentView.swift
#	ghostty
This commit is contained in:
Lawrence Chen 2026-03-09 18:36:59 -07:00
commit bdebc8ecc9
205 changed files with 107859 additions and 6333 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
import Foundation
/// JavaScript snippets for find-in-page in WKWebView.
///
/// Uses TreeWalker to scan text nodes and wraps matches with `<mark>` elements.
/// The current match gets an additional `.current` class and is scrolled into view.
enum BrowserFindJavaScript {
// MARK: - Public API
/// Returns JS that highlights all occurrences of `query` in the document body.
/// The script evaluates to a JSON string `{"total":N,"current":0}`.
static func searchScript(query: String) -> String {
let escaped = jsStringEscape(query)
return """
(() => {
const MARK_CLASS = '__cmux-find';
const CURRENT_CLASS = '__cmux-find-current';
// Remove previous highlights first.
\(clearBody)
const query = "\(escaped)";
if (!query) return JSON.stringify({total: 0, current: 0});
const lowerQuery = query.toLowerCase();
const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','SVG']);
const isVisible = (el) => {
while (el && el !== document.body) {
if (SKIP_TAGS.has(el.tagName)) return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
const st = getComputedStyle(el);
if (st.display === 'none' || st.visibility === 'hidden') return false;
el = el.parentElement;
}
return true;
};
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode(node) { return isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }
);
const matches = [];
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const text = node.textContent || '';
const lowerText = text.toLowerCase();
let startIndex = 0;
const parts = [];
let lastEnd = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, startIndex);
if (idx === -1) break;
parts.push({ start: idx, end: idx + query.length });
startIndex = idx + query.length;
}
if (parts.length === 0) continue;
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
let pos = 0;
for (const part of parts) {
if (part.start > pos) {
frag.appendChild(document.createTextNode(text.substring(pos, part.start)));
}
const mark = document.createElement('mark');
mark.className = MARK_CLASS;
mark.textContent = text.substring(part.start, part.end);
frag.appendChild(mark);
matches.push(mark);
pos = part.end;
}
if (pos < text.length) {
frag.appendChild(document.createTextNode(text.substring(pos)));
}
parent.replaceChild(frag, node);
}
window.__cmuxFindMatches = matches;
window.__cmuxFindIndex = 0;
if (matches.length > 0) {
matches[0].classList.add(CURRENT_CLASS);
matches[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
// Inject highlight styles if not already present.
if (!document.getElementById('__cmux-find-style')) {
const style = document.createElement('style');
style.id = '__cmux-find-style';
style.textContent = `
mark.__cmux-find { background: #facc15; color: #000; border-radius: 2px; }
mark.__cmux-find.__cmux-find-current { background: #f97316; color: #fff; }
`;
document.head.appendChild(style);
}
return JSON.stringify({ total: matches.length, current: 0 });
})()
"""
}
/// Returns JS that moves to the next match. Evaluates to `{"total":N,"current":M}`.
static func nextScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx + 1) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that moves to the previous match. Evaluates to `{"total":N,"current":M}`.
static func previousScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx - 1 + matches.length) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that removes all find highlights and restores the DOM.
static func clearScript() -> String {
"""
(() => {
\(clearBody)
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
const style = document.getElementById('__cmux-find-style');
if (style) style.remove();
return 'ok';
})()
"""
}
// MARK: - Internal
/// JS snippet (no wrapping IIFE) that removes existing mark highlights.
private static let clearBody = """
document.querySelectorAll('mark.__cmux-find').forEach(mark => {
const parent = mark.parentNode;
if (!parent) return;
const text = document.createTextNode(mark.textContent || '');
parent.replaceChild(text, mark);
parent.normalize();
});
"""
/// Escape a Swift string for safe embedding inside a JS double-quoted string literal.
static func jsStringEscape(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for scalar in string.unicodeScalars {
switch scalar {
case "\\": result += "\\\\"
case "\"": result += "\\\""
case "\n": result += "\\n"
case "\r": result += "\\r"
case "\t": result += "\\t"
case "\0": result += "\\0"
case "\u{2028}": result += "\\u2028"
case "\u{2029}": result += "\\u2029"
default:
result.append(Character(scalar))
}
}
return result
}
}

View file

@ -0,0 +1,193 @@
import Bonsplit
import SwiftUI
struct BrowserSearchOverlay: View {
let panelId: UUID
@ObservedObject var searchState: BrowserSearchState
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@FocusState private var isSearchFieldFocused: Bool
private let padding: CGFloat = 8
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
guard maxAttempts > 0 else { return }
isSearchFieldFocused = true
guard maxAttempts > 1 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
}
}
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
.accessibilityIdentifier("BrowserFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
.overlay(alignment: .trailing) {
if let selected = searchState.selected {
let totalText = searchState.total.map { String($0) } ?? "?"
Text("\(selected + 1)/\(totalText)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
} else if let total = searchState.total {
Text(total == 0 ? "0/0" : "-/\(total)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
}
}
.onExitCommand {
onClose()
}
.onSubmit {
// onSubmit fires only after IME composition is committed.
if NSEvent.modifierFlags.contains(.shift) {
onPrevious()
} else {
onNext()
}
}
Button(action: {
#if DEBUG
dlog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))")
#endif
onNext()
}) {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Next match (Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))")
#endif
onPrevious()
}) {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Previous match (Shift+Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))")
#endif
onClose()
}) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Close (Esc)")
}
.padding(8)
.background(.background)
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#endif
requestSearchFieldFocus()
}
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == panelId else { return }
DispatchQueue.main.async {
requestSearchFieldFocus()
}
}
.background(
GeometryReader { barGeo in
Color.clear.onAppear {
barSize = barGeo.size
}
}
)
.padding(padding)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize)
let newCenter = CGPoint(
x: centerPos.x + value.translation.width,
y: centerPos.y + value.translation.height
)
let newCorner = closestCorner(to: newCenter, in: geo.size)
withAnimation(.easeOut(duration: 0.2)) {
corner = newCorner
dragOffset = .zero
}
}
)
}
}
private var clipShape: some Shape {
RoundedRectangle(cornerRadius: 8)
}
enum Corner {
case topLeft
case topRight
case bottomLeft
case bottomRight
var alignment: Alignment {
switch self {
case .topLeft: return .topLeading
case .topRight: return .topTrailing
case .bottomLeft: return .bottomLeading
case .bottomRight: return .bottomTrailing
}
}
}
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
let halfWidth = barSize.width / 2 + padding
let halfHeight = barSize.height / 2 + padding
switch corner {
case .topLeft:
return CGPoint(x: halfWidth, y: halfHeight)
case .topRight:
return CGPoint(x: containerSize.width - halfWidth, y: halfHeight)
case .bottomLeft:
return CGPoint(x: halfWidth, y: containerSize.height - halfHeight)
case .bottomRight:
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
}
}
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
let midX = containerSize.width / 2
let midY = containerSize.height / 2
if point.x < midX {
return point.y < midY ? .topLeft : .bottomLeft
}
return point.y < midY ? .topRight : .bottomRight
}
}

View file

@ -1,33 +1,68 @@
import AppKit
import Bonsplit
import SwiftUI
private extension NSView {
func cmuxAncestor<T: NSView>(of type: T.Type) -> T? {
var current: NSView? = self
while let view = current {
if let target = view as? T {
return target
}
current = view.superview
}
return nil
}
}
struct SurfaceSearchOverlay: View {
let tabId: UUID
let surfaceId: UUID
@ObservedObject var searchState: TerminalSurface.SearchState
let onMoveFocusToTerminal: () -> Void
let onNavigateSearch: (_ action: String) -> Void
let onFieldDidFocus: () -> Void
let onClose: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@FocusState private var isSearchFieldFocused: Bool
@State private var isSearchFieldFocused: Bool = true
private let padding: CGFloat = 8
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
.overlay(alignment: .trailing) {
SearchTextFieldRepresentable(
text: $searchState.needle,
isFocused: $isSearchFieldFocused,
surfaceId: surfaceId,
onFieldDidFocus: onFieldDidFocus,
onEscape: {
#if DEBUG
dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)")
#endif
if searchState.needle.isEmpty {
onClose()
} else {
onMoveFocusToTerminal()
}
},
onReturn: { isShift in
let action = isShift
? "navigate_search:previous"
: "navigate_search:next"
onNavigateSearch(action)
}
)
.accessibilityIdentifier("TerminalFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.overlay(alignment: .trailing) {
if let selected = searchState.selected {
let totalText = searchState.total.map { String($0) } ?? "?"
Text("\(selected + 1)/\(totalText)")
@ -43,20 +78,6 @@ struct SurfaceSearchOverlay: View {
.padding(.trailing, 8)
}
}
.onExitCommand {
if searchState.needle.isEmpty {
onClose()
} else {
onMoveFocusToTerminal()
}
}
.backport.onKeyPress(.return) { modifiers in
let action = modifiers.contains(.shift)
? "navigate_search:previous"
: "navigate_search:next"
onNavigateSearch(action)
return .handled
}
Button(action: {
#if DEBUG
@ -67,7 +88,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.help("Next match (Return)")
.safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
Button(action: {
#if DEBUG
@ -78,7 +99,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.help("Previous match (Shift+Return)")
.safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
Button(action: {
#if DEBUG
@ -89,24 +110,18 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.help("Close (Esc)")
.safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
}
.padding(8)
.background(.background)
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
NSLog("Find: overlay appear tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
#if DEBUG
dlog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))")
#endif
isSearchFieldFocused = true
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
guard let focusedSurface = notification.object as? TerminalSurface,
focusedSurface.id == surfaceId else { return }
NSLog("Find: overlay focus tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
DispatchQueue.main.async {
isSearchFieldFocused = true
}
}
.background(
GeometryReader { barGeo in
Color.clear.onAppear {
@ -185,6 +200,194 @@ struct SurfaceSearchOverlay: View {
}
}
// MARK: - Native Search Text Field (AppKit)
/// NSTextField subclass for the terminal find bar.
/// Strips visual chrome so SwiftUI handles the background/border appearance.
private final class SearchNativeTextField: NSTextField {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isBordered = false
isBezeled = false
drawsBackground = false
focusRingType = .none
usesSingleLineMode = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// NSViewRepresentable wrapping SearchNativeTextField.
/// Handles Escape and Return at the AppKit delegate level, eliminating the
/// SwiftUI @FocusState / AppKit first-responder mismatch that broke focus
/// after window switching.
private struct SearchTextFieldRepresentable: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let surfaceId: UUID
let onFieldDidFocus: () -> Void
let onEscape: () -> Void
let onReturn: (_ isShift: Bool) -> Void
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: SearchTextFieldRepresentable
var isProgrammaticMutation = false
weak var parentField: SearchNativeTextField?
var pendingFocusRequest: Bool?
var searchFocusObserver: NSObjectProtocol?
init(parent: SearchTextFieldRepresentable) {
self.parent = parent
}
deinit {
if let searchFocusObserver {
NotificationCenter.default.removeObserver(searchFocusObserver)
}
}
func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
parent.text = field.stringValue
}
func controlTextDidBeginEditing(_ obj: Notification) {
#if DEBUG
dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
#endif
parent.onFieldDidFocus()
if !parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = true
}
}
}
func controlTextDidEndEditing(_ obj: Notification) {
#if DEBUG
dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
#endif
if parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = false
}
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.cancelOperation(_:)):
// Don't intercept Escape during CJK IME composition (issue #118)
if textView.hasMarkedText() { return false }
control.cmuxAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression()
parent.onEscape()
return true
case #selector(NSResponder.insertNewline(_:)):
if textView.hasMarkedText() { return false }
let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
parent.onReturn(isShift)
return true
default:
return false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> SearchNativeTextField {
let field = SearchNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search")
field.setAccessibilityIdentifier("TerminalFindSearchTextField")
field.delegate = context.coordinator
field.stringValue = text
context.coordinator.parentField = field
// Observe .ghosttySearchFocus to immediately focus from AppKit level.
// This is the primary mechanism for restoring focus after window switches.
context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver(
forName: .ghosttySearchFocus,
object: nil,
queue: .main
) { [weak field, weak coordinator = context.coordinator] notification in
guard let field, let coordinator else { return }
guard let surface = notification.object as? TerminalSurface,
surface.id == coordinator.parent.surfaceId else { return }
guard let window = field.window else { return }
// Don't re-focus if already first responder. makeFirstResponder on an
// already-editing NSTextField ends the editing session and restarts it
// with all text selected, causing typed characters to replace each other.
let fr = window.firstResponder
let alreadyFocused = fr === field ||
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
#if DEBUG
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
#endif
guard !alreadyFocused else { return }
window.makeFirstResponder(field)
}
return field
}
func updateNSView(_ nsView: SearchNativeTextField, context: Context) {
context.coordinator.parent = self
context.coordinator.parentField = nsView
// Sync text from binding to field (skip during active IME composition)
if let editor = nsView.currentEditor() as? NSTextView {
if editor.string != text, !editor.hasMarkedText() {
context.coordinator.isProgrammaticMutation = true
editor.string = text
nsView.stringValue = text
context.coordinator.isProgrammaticMutation = false
}
} else if nsView.stringValue != text {
nsView.stringValue = text
}
// Sync focus from binding to AppKit
if let window = nsView.window {
let fr = window.firstResponder
let isFirstResponder =
fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator, coordinator.parent.isFocused else { return }
guard let nsView, let window = nsView.window else { return }
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
window.makeFirstResponder(nsView)
}
}
}
}
static func dismantleNSView(_ nsView: SearchNativeTextField, coordinator: Coordinator) {
if let observer = coordinator.searchFocusObserver {
NotificationCenter.default.removeObserver(observer)
coordinator.searchFocusObserver = nil
}
nsView.delegate = nil
coordinator.parentField = nil
}
}
struct SearchButtonStyle: ButtonStyle {
@State private var isHovered = false

View file

@ -21,6 +21,7 @@ struct GhosttyConfig {
// Colors (from theme or config)
var backgroundColor: NSColor = NSColor(hex: "#272822")!
var backgroundOpacity: Double = 1.0
var foregroundColor: NSColor = NSColor(hex: "#fdfff1")!
var cursorColor: NSColor = NSColor(hex: "#c0c1b5")!
var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")!
@ -148,6 +149,10 @@ struct GhosttyConfig {
if let color = NSColor(hex: value) {
backgroundColor = color
}
case "background-opacity":
if let opacity = Double(value) {
backgroundOpacity = opacity
}
case "foreground":
if let color = NSColor(hex: value) {
foregroundColor = color

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import AppKit
import Carbon
class KeyboardLayout {
@ -12,8 +13,12 @@ class KeyboardLayout {
return nil
}
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
static func character(forKeyCode keyCode: UInt16) -> String? {
/// Translate a physical keyCode to the character AppKit would use for shortcut matching,
/// preserving command-aware layouts such as "Dvorak - QWERTY Command".
static func character(
forKeyCode keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags = []
) -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
@ -31,7 +36,7 @@ class KeyboardLayout {
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
translationModifierKeyState(for: modifierFlags),
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
@ -43,4 +48,20 @@ class KeyboardLayout {
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: chars, count: length).lowercased()
}
private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 {
let normalized = modifierFlags
.intersection(.deviceIndependentFlagsMask)
.intersection([.shift, .command])
var carbonModifiers: Int = 0
if normalized.contains(.shift) {
carbonModifiers |= shiftKey
}
if normalized.contains(.command) {
carbonModifiers |= cmdKey
}
return UInt32((carbonModifiers >> 8) & 0xFF)
}
}

View file

@ -10,6 +10,7 @@ enum KeyboardShortcutSettings {
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
@ -23,6 +24,7 @@ enum KeyboardShortcutSettings {
case renameWorkspace
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
// Panes / splits
case focusLeft
@ -44,34 +46,36 @@ enum KeyboardShortcutSettings {
var label: String {
switch self {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace"
case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .openFolder: return "Open Folder"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
case .nextSurface: return "Next Surface"
case .prevSurface: return "Previous Surface"
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .renameTab: return "Rename Tab"
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
case .focusLeft: return "Focus Pane Left"
case .focusRight: return "Focus Pane Right"
case .focusUp: return "Focus Pane Up"
case .focusDown: return "Focus Pane Down"
case .splitRight: return "Split Right"
case .splitDown: return "Split Down"
case .toggleSplitZoom: return "Toggle Pane Zoom"
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .openBrowser: return "Open Browser"
case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools"
case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console"
case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar")
case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace")
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface")
case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface")
case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace")
case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace")
case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab")
case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace")
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left")
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down")
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right")
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
}
}
@ -82,6 +86,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@ -102,6 +107,7 @@ enum KeyboardShortcutSettings {
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
@ -120,6 +126,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
@ -160,6 +168,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false)
case .newSurface:
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .toggleTerminalCopyMode:
return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
@ -469,7 +479,7 @@ private class ShortcutRecorderNSButton: NSButton {
func updateTitle() {
if isRecording {
title = "Press shortcut…"
title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…")
} else {
title = shortcut.displayString
}

View file

@ -1,3 +1,4 @@
import Bonsplit
import SwiftUI
struct NotificationsPage: View {
@ -67,7 +68,7 @@ struct NotificationsPage: View {
private var header: some View {
HStack {
Text("Notifications")
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.title2)
.fontWeight(.semibold)
@ -76,7 +77,7 @@ struct NotificationsPage: View {
if !notificationStore.notifications.isEmpty {
jumpToUnreadButton
Button("Clear All") {
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
@ -91,9 +92,9 @@ struct NotificationsPage: View {
Image(systemName: "bell.slash")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No notifications yet")
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
.font(.headline)
Text("Desktop notifications will appear here for quick review.")
Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review."))
.font(.subheadline)
.foregroundColor(.secondary)
}
@ -107,25 +108,25 @@ struct NotificationsPage: View {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
} else {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView {
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
func withPointerFocusAllowance<T>(_ body: () -> T) -> T {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView {
)
#endif
}
body()
return body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView {
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
}
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView {
}
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
return super.performDragOperation(sender)
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
@ -1133,7 +1153,7 @@ final class CmuxWebView: WKWebView {
debugLogContextMenuDownloadCandidate(item, index: index)
if !hasDefaultBrowserOpenLinkItem,
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|| item.title == "Open Link in Default Browser") {
|| item.title == String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser")) {
hasDefaultBrowserOpenLinkItem = true
}
@ -1148,7 +1168,7 @@ final class CmuxWebView: WKWebView {
// by opening the link as a new surface in the same pane.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = "Open Link in New Tab"
item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab")
}
if isDownloadImageMenuItem(item) {
@ -1188,7 +1208,7 @@ final class CmuxWebView: WKWebView {
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
let item = NSMenuItem(
title: "Open Link in Default Browser",
title: String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser"),
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
keyEquivalent: ""
)

View file

@ -0,0 +1,182 @@
import Foundation
import Combine
/// A panel that renders a markdown file with live file-watching.
/// When the file changes on disk, the content is automatically reloaded.
@MainActor
final class MarkdownPanel: Panel, ObservableObject {
let id: UUID
let panelType: PanelType = .markdown
/// Absolute path to the markdown file being displayed.
let filePath: String
/// The workspace this panel belongs to.
private(set) var workspaceId: UUID
/// Current markdown content read from the file.
@Published private(set) var content: String = ""
/// Title shown in the tab bar (filename).
@Published private(set) var displayTitle: String = ""
/// SF Symbol icon for the tab bar.
var displayIcon: String? { "doc.richtext" }
/// Whether the file has been deleted or is unreadable.
@Published private(set) var isFileUnavailable: Bool = false
/// Token incremented to trigger focus flash animation.
@Published private(set) var focusFlashToken: Int = 0
// MARK: - File watching
// nonisolated(unsafe) because deinit is not guaranteed to run on the
// main actor, but DispatchSource.cancel() is thread-safe.
private nonisolated(unsafe) var fileWatchSource: DispatchSourceFileSystemObject?
private var fileDescriptor: Int32 = -1
private var isClosed: Bool = false
private let watchQueue = DispatchQueue(label: "com.cmux.markdown-file-watch", qos: .utility)
/// Maximum number of reattach attempts after a file delete/rename event.
private static let maxReattachAttempts = 6
/// Delay between reattach attempts (total window: attempts * delay = 3s).
private static let reattachDelay: TimeInterval = 0.5
// MARK: - Init
init(workspaceId: UUID, filePath: String) {
self.id = UUID()
self.workspaceId = workspaceId
self.filePath = filePath
self.displayTitle = (filePath as NSString).lastPathComponent
loadFileContent()
startFileWatcher()
if isFileUnavailable && fileWatchSource == nil {
// Session restore can create a panel before the file is recreated.
// Retry briefly so atomic-rename recreations can reconnect.
scheduleReattach(attempt: 1)
}
}
// MARK: - Panel protocol
func focus() {
// Markdown panel is read-only; no first responder to manage.
}
func unfocus() {
// No-op for read-only panel.
}
func close() {
isClosed = true
stopFileWatcher()
}
func triggerFlash() {
focusFlashToken += 1
}
// MARK: - File I/O
private func loadFileContent() {
do {
let newContent = try String(contentsOfFile: filePath, encoding: .utf8)
content = newContent
isFileUnavailable = false
} catch {
// Fallback: try ISO Latin-1, which accepts all 256 byte values,
// covering legacy encodings like Windows-1252.
if let data = FileManager.default.contents(atPath: filePath),
let decoded = String(data: data, encoding: .isoLatin1) {
content = decoded
isFileUnavailable = false
} else {
isFileUnavailable = true
}
}
}
// MARK: - File watcher via DispatchSource
private func startFileWatcher() {
let fd = open(filePath, O_EVTONLY)
guard fd >= 0 else { return }
fileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .extend],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
// File was deleted or renamed. The old file descriptor points to
// a stale inode, so we must always stop and reattach the watcher
// even if the new file is already readable (atomic save case).
DispatchQueue.main.async {
self.stopFileWatcher()
self.loadFileContent()
if self.isFileUnavailable {
// File not yet replaced retry until it reappears.
self.scheduleReattach(attempt: 1)
} else {
// File already replaced reattach to the new inode immediately.
self.startFileWatcher()
}
}
} else {
// Content changed reload.
DispatchQueue.main.async {
self.loadFileContent()
}
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
fileWatchSource = source
}
/// Retry reattaching the file watcher up to `maxReattachAttempts` times.
/// Each attempt checks if the file has reappeared. Bails out early if
/// the panel has been closed.
private func scheduleReattach(attempt: Int) {
guard attempt <= Self.maxReattachAttempts else { return }
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard !self.isClosed else { return }
if FileManager.default.fileExists(atPath: self.filePath) {
self.isFileUnavailable = false
self.loadFileContent()
self.startFileWatcher()
} else {
self.scheduleReattach(attempt: attempt + 1)
}
}
}
}
private func stopFileWatcher() {
if let source = fileWatchSource {
source.cancel()
fileWatchSource = nil
}
// File descriptor is closed by the cancel handler.
fileDescriptor = -1
}
deinit {
// DispatchSource cancel is safe from any thread.
fileWatchSource?.cancel()
}
}

View file

@ -0,0 +1,355 @@
import AppKit
import SwiftUI
import MarkdownUI
/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
struct MarkdownPanelView: View {
@ObservedObject var panel: MarkdownPanel
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashAnimationGeneration: Int = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Group {
if panel.isFileUnavailable {
fileUnavailableView
} else {
markdownContentView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay {
if isVisibleInUI {
// Observe left-clicks without intercepting them so markdown text
// selection and link activation continue to use the native path.
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
}
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
}
}
// MARK: - Content
private var markdownContentView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// File path breadcrumb
filePathHeader
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
Divider()
.padding(.horizontal, 16)
// Rendered markdown
Markdown(panel.content)
.markdownTheme(cmuxMarkdownTheme)
.textSelection(.enabled)
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
private var filePathHeader: some View {
HStack(spacing: 6) {
Image(systemName: "doc.richtext")
.foregroundColor(.secondary)
.font(.system(size: 12))
Text(panel.filePath)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
}
private var fileUnavailableView: some View {
VStack(spacing: 12) {
Image(systemName: "doc.questionmark")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable"))
.font(.headline)
.foregroundColor(.primary)
Text(panel.filePath)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)
Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted."))
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Theme
private var backgroundColor: Color {
colorScheme == .dark
? Color(nsColor: NSColor(white: 0.12, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.98, alpha: 1.0))
}
private var cmuxMarkdownTheme: Theme {
let isDark = colorScheme == .dark
return Theme()
// Text
.text {
ForegroundColor(isDark ? .white.opacity(0.9) : .primary)
FontSize(14)
}
// Headings
.heading1 { configuration in
VStack(alignment: .leading, spacing: 8) {
configuration.label
.markdownTextStyle {
FontWeight(.bold)
FontSize(28)
ForegroundColor(isDark ? .white : .primary)
}
Divider()
}
.markdownMargin(top: 24, bottom: 16)
}
.heading2 { configuration in
VStack(alignment: .leading, spacing: 6) {
configuration.label
.markdownTextStyle {
FontWeight(.bold)
FontSize(22)
ForegroundColor(isDark ? .white : .primary)
}
Divider()
}
.markdownMargin(top: 20, bottom: 12)
}
.heading3 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(18)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 16, bottom: 8)
}
.heading4 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(16)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 12, bottom: 6)
}
.heading5 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.medium)
FontSize(14)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 10, bottom: 4)
}
.heading6 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.medium)
FontSize(13)
ForegroundColor(isDark ? .white.opacity(0.7) : .secondary)
}
.markdownMargin(top: 8, bottom: 4)
}
// Code blocks
.codeBlock { configuration in
ScrollView(.horizontal, showsIndicators: true) {
configuration.label
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(13)
ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2))
}
.padding(12)
}
.background(isDark
? Color(nsColor: NSColor(white: 0.08, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.93, alpha: 1.0)))
.clipShape(RoundedRectangle(cornerRadius: 6))
.markdownMargin(top: 8, bottom: 8)
}
// Inline code
.code {
FontFamilyVariant(.monospaced)
FontSize(13)
ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7))
BackgroundColor(isDark
? Color(nsColor: NSColor(white: 0.18, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.92, alpha: 1.0)))
}
// Block quotes
.blockquote { configuration in
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4))
.frame(width: 3)
configuration.label
.markdownTextStyle {
ForegroundColor(isDark ? .white.opacity(0.6) : .secondary)
FontSize(14)
}
.padding(.leading, 12)
}
.markdownMargin(top: 8, bottom: 8)
}
// Links
.link {
ForegroundColor(Color.accentColor)
}
// Strong
.strong {
FontWeight(.semibold)
}
// Tables
.table { configuration in
configuration.label
.markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3)))
.markdownTableBackgroundStyle(
.alternatingRows(
isDark
? Color(nsColor: NSColor(white: 0.14, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.96, alpha: 1.0)),
isDark
? Color(nsColor: NSColor(white: 0.10, alpha: 1.0))
: Color(nsColor: NSColor(white: 1.0, alpha: 1.0))
)
)
.markdownMargin(top: 8, bottom: 8)
}
// Thematic break (horizontal rule)
.thematicBreak {
Divider()
.markdownMargin(top: 16, bottom: 16)
}
// List items
.listItem { configuration in
configuration.label
.markdownMargin(top: 4, bottom: 4)
}
// Paragraphs
.paragraph { configuration in
configuration.label
.markdownMargin(top: 4, bottom: 8)
}
}
// MARK: - Focus Flash
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
}
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
}
private struct MarkdownPointerObserver: NSViewRepresentable {
let onPointerDown: () -> Void
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
let view = MarkdownPanelPointerObserverView()
view.onPointerDown = onPointerDown
return view
}
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
nsView.onPointerDown = onPointerDown
}
}
final class MarkdownPanelPointerObserverView: NSView {
var onPointerDown: (() -> Void)?
private var eventMonitor: Any?
override var mouseDownCanMoveWindow: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
installEventMonitorIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
func shouldHandle(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown,
let window,
event.window === window,
!isHiddenOrHasHiddenAncestor else { return false }
let point = convert(event.locationInWindow, from: nil)
return bounds.contains(point)
}
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
guard shouldHandle(event) else { return event }
DispatchQueue.main.async { [weak self] in
self?.onPointerDown?()
}
return event
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
self?.handleEventIfNeeded(event) ?? event
}
}
}

View file

@ -5,6 +5,7 @@ import Combine
public enum PanelType: String, Codable, Sendable {
case terminal
case browser
case markdown
}
enum FocusFlashCurve: Equatable {

View file

@ -1,9 +1,11 @@
import SwiftUI
import Foundation
import Bonsplit
/// View that renders the appropriate panel view based on panel type
struct PanelContentView: View {
let panel: any Panel
let paneId: PaneID
let isFocused: Bool
let isSelectedInPane: Bool
let isVisibleInUI: Bool
@ -35,6 +37,17 @@ struct PanelContentView: View {
if let browserPanel = panel as? BrowserPanel {
BrowserPanelView(
panel: browserPanel,
paneId: paneId,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,
onRequestPanelFocus: onRequestPanelFocus
)
}
case .markdown:
if let markdownPanel = panel as? MarkdownPanel {
MarkdownPanelView(
panel: markdownPanel,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,

View file

@ -1,6 +1,7 @@
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.
@ -164,9 +165,28 @@ final class TerminalPanel: Panel, ObservableObject {
// 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() {
@ -195,6 +215,10 @@ final class TerminalPanel: Panel, ObservableObject {
hostedView.triggerFlash()
}
func triggerNotificationDismissFlash() {
hostedView.triggerFlash(style: .notificationDismiss)
}
func applyWindowBackgroundIfActive() {
surface.applyWindowBackgroundIfActive()
}

View file

@ -2,7 +2,6 @@ import AppKit
import Foundation
import PostHog
@MainActor
final class PostHogAnalytics {
static let shared = PostHogAnalytics()
@ -12,12 +11,27 @@ final class PostHogAnalytics {
// PostHog Cloud US default (matches other cmux properties).
private let host = "https://us.i.posthog.com"
private let dailyActiveEvent = "cmux_daily_active"
private let hourlyActiveEvent = "cmux_hourly_active"
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC"
private let workQueue: DispatchQueue
private let workQueueSpecificKey = DispatchSpecificKey<Void>()
private let utcHourFormatter: DateFormatter
private let utcDayFormatter: DateFormatter
private var didStart = false
private var activeCheckTimer: Timer?
private init() {
workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility)
utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH")
utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd")
workQueue.setSpecific(key: workQueueSpecificKey, value: ())
}
private var isEnabled: Bool {
guard TelemetrySettings.enabledForCurrentLaunch else { return false }
#if DEBUG
@ -29,6 +43,44 @@ final class PostHogAnalytics {
}
func startIfNeeded() {
dispatchAsyncOnWorkQueue { [weak self] in
self?.startIfNeededOnWorkQueue()
}
}
func trackActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
guard let self else { return }
let didCaptureDaily = self.trackDailyActiveOnWorkQueue(reason: reason, flush: false)
let didCaptureHourly = self.trackHourlyActiveOnWorkQueue(reason: reason, flush: false)
if didCaptureDaily || didCaptureHourly {
// On app focus we can capture both events; flush once to reduce extra work.
PostHogSDK.shared.flush()
}
}
}
func trackDailyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackDailyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func trackHourlyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackHourlyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func flush() {
dispatchSyncOnWorkQueue {
guard didStart else { return }
PostHogSDK.shared.flush()
}
}
private func startIfNeededOnWorkQueue() {
guard !didStart else { return }
guard isEnabled else { return }
@ -49,31 +101,40 @@ final class PostHogAnalytics {
didStart = true
scheduleActiveCheckTimer()
}
private func scheduleActiveCheckTimer() {
// If the app stays in the foreground across midnight, `applicationDidBecomeActive`
// won't fire again, so a periodic check avoids undercounting those users.
activeCheckTimer?.invalidate()
activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard NSApp.isActive else { return }
self.trackDailyActive(reason: "activeTimer")
self.trackHourlyActive(reason: "activeTimer")
self.activeCheckTimer?.invalidate()
self.activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
guard let self else { return }
guard NSApp.isActive else { return }
self.trackActive(reason: "activeTimer")
}
}
}
func trackDailyActive(reason: String) {
startIfNeeded()
guard didStart else { return }
@discardableResult
private func trackDailyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
startIfNeededOnWorkQueue()
guard didStart else { return false }
let today = utcDayString(Date())
let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveDayUTCKey) == today {
return
return false
}
defaults.set(today, forKey: lastActiveDayUTCKey)
let event = dailyActiveEvent
PostHogSDK.shared.capture(
"cmux_daily_active",
event,
properties: Self.dailyActiveProperties(
dayUTC: today,
reason: reason,
@ -81,53 +142,77 @@ final class PostHogAnalytics {
)
)
// For DAU we care more about delivery than batching.
PostHogSDK.shared.flush()
if flush && Self.shouldFlushAfterCapture(event: event) {
// For active metrics we care more about delivery than batching.
PostHogSDK.shared.flush()
}
return true
}
func trackHourlyActive(reason: String) {
startIfNeeded()
guard didStart else { return }
@discardableResult
private func trackHourlyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
startIfNeededOnWorkQueue()
guard didStart else { return false }
let hour = utcHourString(Date())
let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveHourUTCKey) == hour {
return
return false
}
defaults.set(hour, forKey: lastActiveHourUTCKey)
let event = hourlyActiveEvent
PostHogSDK.shared.capture(
"cmux_hourly_active",
event,
properties: Self.hourlyActiveProperties(
hourUTC: hour,
reason: reason,
infoDictionary: Bundle.main.infoDictionary ?? [:]
)
)
if flush && Self.shouldFlushAfterCapture(event: event) {
// Keep hourly freshness and avoid losing a deduped hour on abrupt exits.
PostHogSDK.shared.flush()
}
return true
}
func flush() {
guard didStart else { return }
PostHogSDK.shared.flush()
private func dispatchAsyncOnWorkQueue(_ block: @escaping () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.async(execute: block)
}
private func dispatchSyncOnWorkQueue(_ block: () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.sync(execute: block)
}
private func utcHourString(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH"
return formatter.string(from: date)
utcHourFormatter.string(from: date)
}
private func utcDayString(_ date: Date) -> String {
utcDayFormatter.string(from: date)
}
private static func makeUTCFormatter(_ dateFormat: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
formatter.dateFormat = dateFormat
return formatter
}
nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] {
@ -162,6 +247,15 @@ final class PostHogAnalytics {
return properties
}
nonisolated static func shouldFlushAfterCapture(event: String) -> Bool {
switch event {
case "cmux_daily_active", "cmux_hourly_active":
return true
default:
return false
}
}
nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] {
var properties: [String: Any] = [:]
if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty {

View file

@ -235,6 +235,10 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable {
var forwardHistoryURLStrings: [String]?
}
struct SessionMarkdownPanelSnapshot: Codable, Sendable {
var filePath: String
}
struct SessionPanelSnapshot: Codable, Sendable {
var id: UUID
var type: PanelType
@ -248,6 +252,7 @@ struct SessionPanelSnapshot: Codable, Sendable {
var ttyName: String?
var terminal: SessionTerminalPanelSnapshot?
var browser: SessionBrowserPanelSnapshot?
var markdown: SessionMarkdownPanelSnapshot?
}
enum SessionSplitOrientation: String, Codable, Sendable {

View file

@ -18,30 +18,30 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .off:
return "Off"
return String(localized: "socketControl.off.name", defaultValue: "Off")
case .cmuxOnly:
return "cmux processes only"
return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only")
case .automation:
return "Automation mode"
return String(localized: "socketControl.automation.name", defaultValue: "Automation mode")
case .password:
return "Password mode"
return String(localized: "socketControl.password.name", defaultValue: "Password mode")
case .allowAll:
return "Full open access"
return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access")
}
}
var description: String {
switch self {
case .off:
return "Disable the local control socket."
return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.")
case .cmuxOnly:
return "Only processes started inside cmux terminals can send commands."
return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.")
case .automation:
return "Allow external local automation clients from this macOS user (no ancestry check)."
return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).")
case .password:
return "Require socket authentication with a password stored in a local file."
return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.")
case .allowAll:
return "Allow any local process and user to connect with no auth. Unsafe."
return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.")
}
}
@ -183,7 +183,7 @@ enum SocketControlPasswordStore {
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileNoSuchFileError,
userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."]
userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")]
)
}
let directory = fileURL.deletingLastPathComponent()

View file

@ -19,22 +19,22 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .top:
return "Top"
return String(localized: "workspace.placement.top", defaultValue: "Top")
case .afterCurrent:
return "After current"
return String(localized: "workspace.placement.afterCurrent", defaultValue: "After current")
case .end:
return "End"
return String(localized: "workspace.placement.end", defaultValue: "End")
}
}
var description: String {
switch self {
case .top:
return "Insert new workspaces at the top of the list."
return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.")
case .afterCurrent:
return "Insert new workspaces directly after the active workspace."
return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.")
case .end:
return "Append new workspaces to the bottom of the list."
return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.")
}
}
}
@ -72,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return "Solid Fill"
return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill")
}
}
}
@ -558,13 +558,23 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
}
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
weak var window: NSWindow?
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
@ -617,6 +627,12 @@ class TabManager: ObservableObject {
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
private let initialWorkspaceGitProbeQueue = DispatchQueue(
label: "com.cmux.initial-workspace-git-probe",
qos: .utility
)
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@ -712,19 +728,34 @@ class TabManager: ObservableObject {
}
var isFindVisible: Bool {
selectedTerminalPanel?.searchState != nil
if selectedTerminalPanel?.searchState != nil { return true }
if focusedBrowserPanel?.searchState != nil { return true }
return false
}
var canUseSelectionForFind: Bool {
selectedTerminalPanel?.hasSelection() == true
if focusedBrowserPanel != nil { return false }
return selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
guard let panel = selectedTerminalPanel else { return }
if panel.searchState == nil {
if let browser = focusedBrowserPanel {
browser.startFind()
return
}
guard let panel = selectedTerminalPanel else {
#if DEBUG
dlog("find.startSearch SKIPPED no selectedTerminalPanel")
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}
@ -734,20 +765,43 @@ class TabManager: ObservableObject {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findNext()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findPrevious()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
func toggleFocusedTerminalCopyMode() -> Bool {
guard let panel = selectedTerminalPanel else { return false }
return panel.surface.toggleKeyboardCopyMode()
}
func hideFind() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.hideFind()
return
}
#if DEBUG
dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
selectedTerminalPanel?.searchState = nil
}
@ -757,10 +811,12 @@ class TabManager: ObservableObject {
initialTerminalCommand: String? = nil,
initialTerminalEnvironment: [String: String] = [:],
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -779,6 +835,18 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
@ -797,9 +865,187 @@ class TabManager: ObservableObject {
return newWorkspace
}
private func scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: UUID,
panelId: UUID,
directory: String
) {
let normalizedDirectory = normalizeDirectory(directory)
let generation = UUID()
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
#if DEBUG
dlog(
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
)
#endif
let delays = Self.initialWorkspaceGitProbeDelays
var timers: [DispatchSourceTimer] = []
for (index, delay) in delays.enumerated() {
let isLastAttempt = index == delays.count - 1
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
timer.schedule(deadline: .now() + delay, repeating: .never)
timer.setEventHandler { [weak self] in
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
Task { @MainActor [weak self] in
self?.applyInitialWorkspaceGitMetadataSnapshot(
snapshot,
generation: generation,
workspaceId: workspaceId,
panelId: panelId,
expectedDirectory: normalizedDirectory,
isLastAttempt: isLastAttempt
)
}
}
timers.append(timer)
timer.resume()
}
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
}
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
return
}
for timer in timers {
timer.setEventHandler {}
timer.cancel()
}
}
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
}
private func applyInitialWorkspaceGitMetadataSnapshot(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
generation: UUID,
workspaceId: UUID,
panelId: UUID,
expectedDirectory: String,
isLastAttempt: Bool
) {
defer {
if isLastAttempt,
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
}
}
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
guard workspace.panels[panelId] != nil else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
let currentDirectory = normalizedWorkingDirectory(
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
)
if let currentDirectory, currentDirectory != expectedDirectory {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
#if DEBUG
dlog(
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
"expected=\(expectedDirectory) current=\(currentDirectory)"
)
#endif
return
}
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
let nextBranch = snapshot.branch
if let nextBranch {
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
} else {
workspace.clearPanelGitBranch(panelId: panelId)
}
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
workspace.clearPanelPullRequest(panelId: panelId)
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
dlog(
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
)
#endif
}
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
}
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
let process = Process()
let stdout = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory] + arguments
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
return String(data: data, encoding: .utf8)
}
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
}
// Keep addTab as convenience alias
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal)
}
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
@ -925,6 +1171,16 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: insertIndex)
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
@ -1022,21 +1278,23 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownRemoteConnection()
unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels()
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
tabs.remove(at: index)
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
}
@ -1045,6 +1303,7 @@ class TabManager: ObservableObject {
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId)
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
@ -1106,9 +1365,13 @@ class TabManager: ObservableObject {
let count = plan.panelIds.count
let titleLines = plan.titles.map { "\($0)" }.joined(separator: "\n")
let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
let message = if count == 1 {
String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)")
} else {
String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)")
}
guard confirmClose(
title: "Close other tabs?",
title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
message: message,
acceptCmdD: false
) else { return }
@ -1148,8 +1411,8 @@ class TabManager: ObservableObject {
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
@ -1210,15 +1473,15 @@ class TabManager: ObservableObject {
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return "Untitled Tab"
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
acceptCmdD: willCloseWindow
) {
return
@ -1259,8 +1522,8 @@ class TabManager: ObservableObject {
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
@ -1268,7 +1531,7 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
@ -1300,8 +1563,8 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
@ -1339,8 +1602,8 @@ class TabManager: ObservableObject {
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else { return }
}
@ -1567,19 +1830,37 @@ class TabManager: ObservableObject {
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
@discardableResult
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true)
}
@discardableResult
private func dismissNotificationIfActive(
tabId: UUID,
surfaceId: UUID?,
triggerFlash: Bool
) -> Bool {
guard selectedTabId == tabId else { return false }
guard AppFocusState.isAppActive() else { return false }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
if triggerFlash,
let panelId = surfaceId,
let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
return true
}
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
@ -1864,9 +2145,24 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=terminal dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
#if DEBUG
dlog(
"split.create.result kind=terminal dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
}
/// Create a new browser split from the currently focused panel.
@ -1875,14 +2171,30 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return nil }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=browser dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
return newBrowserSplit(
let createdPanelId = newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
#if DEBUG
dlog(
"split.create.result kind=browser dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
return createdPanelId
}
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
@ -1983,11 +2295,20 @@ class TabManager: ObservableObject {
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
let createdPanel = tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst
)?.id
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.newSurface result dir=\(directionLabel) " +
"tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " +
"created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
return createdPanel
}
/// Move focus in the specified direction
@ -2584,7 +2905,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}
@ -3550,6 +3871,15 @@ enum SplitDirection {
var insertFirst: Bool {
self == .left || self == .up
}
var debugLabel: String {
switch self {
case .left: return "left"
case .right: return "right"
case .up: return "up"
case .down: return "down"
}
}
}
/// Resize direction for backwards compatibility
@ -3561,11 +3891,14 @@ extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested")
static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,13 @@ final class WindowTerminalHostView: NSView {
private var lastDragRouteSignature: String?
#endif
deinit {
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject {
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
// settles a tick before the terminal's own scroll/surface hierarchy. Only force an
// in-place surface refresh when reconciliation actually changed terminal geometry.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
if hostedView.reconcileGeometryNow() {
hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync")
}
}
}
@ -726,6 +734,7 @@ final class WindowTerminalPortal: NSObject {
guard let window else { return false }
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
else { return false }
let browserHost = preferredBrowserHost(in: container)
if hostView.superview !== container ||
installedContainerView !== container ||
@ -734,7 +743,11 @@ final class WindowTerminalPortal: NSObject {
installConstraints.removeAll()
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: reference)
if let browserHost {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
} else {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
installConstraints = [
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
@ -745,6 +758,10 @@ final class WindowTerminalPortal: NSObject {
NSLayoutConstraint.activate(installConstraints)
installedContainerView = container
installedReferenceView = reference
} else if let browserHost {
if !Self.isView(browserHost, above: hostView, in: container) {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
}
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
@ -837,6 +854,10 @@ final class WindowTerminalPortal: NSObject {
return viewIndex > referenceIndex
}
private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? {
container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView
}
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView
@ -1379,7 +1400,7 @@ final class WindowTerminalPortal: NSObject {
hostedView.frame = targetFrame
CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.frameChange")
}
if hasFiniteFrame {
@ -1418,7 +1439,7 @@ final class WindowTerminalPortal: NSObject {
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.reveal")
}
if transientRecoveryReason == nil {
@ -1488,6 +1509,55 @@ final class WindowTerminalPortal: NSObject {
}
#if DEBUG
struct DebugStats {
let windowNumber: Int
let entryCount: Int
let hostSubviewCount: Int
let terminalSubviewCount: Int
let mappedTerminalSubviewCount: Int
let orphanTerminalSubviewCount: Int
let visibleOrphanTerminalSubviewCount: Int
let staleEntryCount: Int
}
func debugStats() -> DebugStats {
let terminalSubviews = hostView.subviews.compactMap { $0 as? GhosttySurfaceScrollView }
var mappedTerminalSubviewCount = 0
var orphanTerminalSubviewCount = 0
var visibleOrphanTerminalSubviewCount = 0
for hostedView in terminalSubviews {
let hostedId = ObjectIdentifier(hostedView)
if entriesByHostedId[hostedId] != nil {
mappedTerminalSubviewCount += 1
} else {
orphanTerminalSubviewCount += 1
if hostedView.window != nil,
!hostedView.isHidden,
hostedView.frame.width > Self.tinyHideThreshold,
hostedView.frame.height > Self.tinyHideThreshold {
visibleOrphanTerminalSubviewCount += 1
}
}
}
let staleEntryCount = entriesByHostedId.values.reduce(0) { partialResult, entry in
guard let hostedView = entry.hostedView else { return partialResult + 1 }
return hostedView.superview === hostView ? partialResult : partialResult + 1
}
return DebugStats(
windowNumber: window?.windowNumber ?? -1,
entryCount: entriesByHostedId.count,
hostSubviewCount: hostView.subviews.count,
terminalSubviewCount: terminalSubviews.count,
mappedTerminalSubviewCount: mappedTerminalSubviewCount,
orphanTerminalSubviewCount: orphanTerminalSubviewCount,
visibleOrphanTerminalSubviewCount: visibleOrphanTerminalSubviewCount,
staleEntryCount: staleEntryCount
)
}
func debugEntryCount() -> Int {
entriesByHostedId.count
}
@ -1540,6 +1610,30 @@ final class WindowTerminalPortal: NSObject {
enum TerminalWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
#if DEBUG
private static var blockedBindCount: Int = 0
private static var blockedBindReasons: [String: Int] = [:]
#endif
private static func bindBlockReason(
expectedSurfaceId: UUID?,
expectedGeneration: UInt64?,
actual: (surfaceId: UUID?, generation: UInt64?, state: String)
) -> String {
if actual.surfaceId == nil {
return "missingSurface"
}
if actual.state != "live" {
return "state_\(actual.state)"
}
if let expectedSurfaceId, actual.surfaceId != expectedSurfaceId {
return "surfaceMismatch"
}
if let expectedGeneration, actual.generation != expectedGeneration {
return "generationMismatch"
}
return "guardRejected"
}
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
@ -1603,11 +1697,46 @@ enum TerminalWindowPortalRegistry {
return portal
}
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
static func bind(
hostedView: GhosttySurfaceScrollView,
to anchorView: NSView,
visibleInUI: Bool,
zPriority: Int = 0,
expectedSurfaceId: UUID? = nil,
expectedGeneration: UInt64? = nil
) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let hostedId = ObjectIdentifier(hostedView)
let guardState = hostedView.portalBindingGuardState()
guard hostedView.canAcceptPortalBinding(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration
) else {
if let oldWindowId = hostedToWindowId.removeValue(forKey: hostedId) {
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
}
#if DEBUG
let reason = bindBlockReason(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration,
actual: guardState
)
blockedBindCount += 1
blockedBindReasons[reason, default: 0] += 1
dlog(
"portal.bind.blocked hosted=\(portalDebugToken(hostedView)) " +
"reason=\(reason) expectedSurface=\(expectedSurfaceId?.uuidString.prefix(5) ?? "nil") " +
"expectedGeneration=\(expectedGeneration.map { String($0) } ?? "nil") " +
"actualSurface=\(guardState.surfaceId?.uuidString.prefix(5) ?? "nil") " +
"actualGeneration=\(guardState.generation.map { String($0) } ?? "nil") " +
"actualState=\(guardState.state)"
)
#endif
return
}
let nextPortal = portal(for: window)
if let oldWindowId = hostedToWindowId[hostedId],
@ -1674,5 +1803,68 @@ enum TerminalWindowPortalRegistry {
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
static func debugPortalStats() -> [String: Any] {
var portals: [[String: Any]] = []
var totals: [String: Int] = [
"entry_count": 0,
"host_subview_count": 0,
"terminal_subview_count": 0,
"mapped_terminal_subview_count": 0,
"orphan_terminal_subview_count": 0,
"visible_orphan_terminal_subview_count": 0,
"stale_entry_count": 0,
"mapped_hosted_count": 0,
]
for (windowId, portal) in portalsByWindowId {
let stats = portal.debugStats()
let mappedHostedCount = hostedToWindowId.values.reduce(0) { partialResult, mappedWindowId in
partialResult + (mappedWindowId == windowId ? 1 : 0)
}
let integrityOK =
stats.orphanTerminalSubviewCount == 0 &&
stats.visibleOrphanTerminalSubviewCount == 0 &&
stats.staleEntryCount == 0 &&
mappedHostedCount == stats.entryCount
portals.append([
"window_number": stats.windowNumber,
"entry_count": stats.entryCount,
"mapped_hosted_count": mappedHostedCount,
"host_subview_count": stats.hostSubviewCount,
"terminal_subview_count": stats.terminalSubviewCount,
"mapped_terminal_subview_count": stats.mappedTerminalSubviewCount,
"orphan_terminal_subview_count": stats.orphanTerminalSubviewCount,
"visible_orphan_terminal_subview_count": stats.visibleOrphanTerminalSubviewCount,
"stale_entry_count": stats.staleEntryCount,
"integrity_ok": integrityOK,
])
totals["entry_count", default: 0] += stats.entryCount
totals["host_subview_count", default: 0] += stats.hostSubviewCount
totals["terminal_subview_count", default: 0] += stats.terminalSubviewCount
totals["mapped_terminal_subview_count", default: 0] += stats.mappedTerminalSubviewCount
totals["orphan_terminal_subview_count", default: 0] += stats.orphanTerminalSubviewCount
totals["visible_orphan_terminal_subview_count", default: 0] += stats.visibleOrphanTerminalSubviewCount
totals["stale_entry_count", default: 0] += stats.staleEntryCount
totals["mapped_hosted_count", default: 0] += mappedHostedCount
}
portals.sort {
let lhs = ($0["window_number"] as? Int) ?? Int.min
let rhs = ($1["window_number"] as? Int) ?? Int.min
return lhs < rhs
}
return [
"portal_count": portals.count,
"hosted_mapping_count": hostedToWindowId.count,
"guarded_bind_blocked_count": blockedBindCount,
"guarded_bind_blocked_reasons": blockedBindReasons,
"portals": portals,
"totals": totals,
]
}
#endif
}

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import Foundation
import SwiftUI
@ -54,7 +55,7 @@ struct UpdatePill: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(model.text)
.safeHelp(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}
@ -72,7 +73,7 @@ struct InstallUpdateMenuItem: View {
var body: some View {
if model.state.isInstallable {
Button("Install Update and Relaunch") {
Button(String(localized: "update.installAndRelaunch", defaultValue: "Install Update and Relaunch")) {
model.state.confirm()
}
}

View file

@ -49,17 +49,17 @@ fileprivate struct PermissionRequestView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Enable automatic updates?")
Text(String(localized: "update.popover.enableAutoUpdates", defaultValue: "Enable automatic updates?"))
.font(.system(size: 13, weight: .semibold))
Text("cmux can automatically check for updates in the background.")
Text(String(localized: "update.popover.autoUpdatesDescription", defaultValue: "cmux can automatically check for updates in the background."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("Not Now") {
Button(String(localized: "common.notNow", defaultValue: "Not Now")) {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: false,
sendSystemProfile: false))
@ -69,7 +69,7 @@ fileprivate struct PermissionRequestView: View {
Spacer()
Button("Allow") {
Button(String(localized: "common.allow", defaultValue: "Allow")) {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: true,
sendSystemProfile: false))
@ -92,13 +92,13 @@ fileprivate struct CheckingView: View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Checking for updates…")
Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…"))
.font(.system(size: 13))
}
HStack {
Spacer()
Button("Cancel") {
Button(String(localized: "common.cancel", defaultValue: "Cancel")) {
checking.cancel()
dismiss()
}
@ -120,12 +120,12 @@ fileprivate struct UpdateAvailableView: View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text("Update Available")
Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("Version:")
Text(String(localized: "update.popover.version", defaultValue: "Version:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(update.appcastItem.displayVersionString)
@ -134,7 +134,7 @@ fileprivate struct UpdateAvailableView: View {
if update.appcastItem.contentLength > 0 {
HStack(spacing: 6) {
Text("Size:")
Text(String(localized: "update.popover.size", defaultValue: "Size:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file))
@ -144,7 +144,7 @@ fileprivate struct UpdateAvailableView: View {
if let date = update.appcastItem.date {
HStack(spacing: 6) {
Text("Released:")
Text(String(localized: "update.popover.released", defaultValue: "Released:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(date.formatted(date: .abbreviated, time: .omitted))
@ -156,13 +156,13 @@ fileprivate struct UpdateAvailableView: View {
}
HStack(spacing: 8) {
Button("Skip") {
Button(String(localized: "common.skip", defaultValue: "Skip")) {
update.reply(.skip)
dismiss()
}
.controlSize(.small)
Button("Later") {
Button(String(localized: "common.later", defaultValue: "Later")) {
update.reply(.dismiss)
dismiss()
}
@ -171,7 +171,7 @@ fileprivate struct UpdateAvailableView: View {
Spacer()
Button("Install and Relaunch") {
Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) {
update.reply(.install)
dismiss()
}
@ -214,7 +214,7 @@ fileprivate struct DownloadingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Downloading Update")
Text(String(localized: "update.popover.downloadingUpdate", defaultValue: "Downloading Update"))
.font(.system(size: 13, weight: .semibold))
if let expectedLength = download.expectedLength, expectedLength > 0 {
@ -233,7 +233,7 @@ fileprivate struct DownloadingView: View {
HStack {
Spacer()
Button("Cancel") {
Button(String(localized: "common.cancel", defaultValue: "Cancel")) {
download.cancel()
dismiss()
}
@ -250,7 +250,7 @@ fileprivate struct ExtractingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Preparing Update")
Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 6) {
@ -271,17 +271,17 @@ fileprivate struct InstallingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
Text(String(localized: "update.popover.restartRequired", defaultValue: "Restart Required"))
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
Text(String(localized: "update.popover.restartRequired.message", defaultValue: "The update is ready. Please restart the application to complete the installation."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Restart Later") {
Button(String(localized: "common.restartLater", defaultValue: "Restart Later")) {
installing.dismiss()
dismiss()
}
@ -290,7 +290,7 @@ fileprivate struct InstallingView: View {
Spacer()
Button("Restart Now") {
Button(String(localized: "common.restartNow", defaultValue: "Restart Now")) {
installing.retryTerminatingApplication()
dismiss()
}
@ -310,10 +310,10 @@ fileprivate struct NotFoundView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("No Updates Found")
Text(String(localized: "update.popover.noUpdatesFound", defaultValue: "No Updates Found"))
.font(.system(size: 13, weight: .semibold))
Text("You're already running the latest version.")
Text(String(localized: "update.popover.noUpdatesFound.message", defaultValue: "You're already running the latest version."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
@ -321,7 +321,7 @@ fileprivate struct NotFoundView: View {
HStack {
Spacer()
Button("OK") {
Button(String(localized: "common.ok", defaultValue: "OK")) {
notFound.acknowledgement()
dismiss()
}
@ -363,7 +363,7 @@ fileprivate struct UpdateErrorView: View {
}
VStack(alignment: .leading, spacing: 6) {
Text("Details")
Text(String(localized: "update.popover.details", defaultValue: "Details"))
.font(.system(size: 11, weight: .semibold))
Text(details)
.font(.system(size: 10, design: .monospaced))
@ -373,14 +373,14 @@ fileprivate struct UpdateErrorView: View {
}
HStack(spacing: 8) {
Button("Copy Details") {
Button(String(localized: "common.copyDetails", defaultValue: "Copy Details")) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(details, forType: .string)
}
.controlSize(.small)
Button("OK") {
Button(String(localized: "common.ok", defaultValue: "OK")) {
error.dismiss()
dismiss()
}
@ -389,7 +389,7 @@ fileprivate struct UpdateErrorView: View {
Spacer()
Button("Retry") {
Button(String(localized: "common.retry", defaultValue: "Retry")) {
error.retry()
dismiss()
}

View file

@ -237,7 +237,7 @@ struct TitlebarControlsView: View {
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@State private var shortcutRefreshTick = 0
@StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor()
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
private let titlebarHintRightSafetyShift: CGFloat = 10
private let titlebarHintBaseXShift: CGFloat = -10
private let titlebarHintBaseYShift: CGFloat = 1
@ -269,11 +269,11 @@ struct TitlebarControlsView: View {
}
private var shouldShowTitlebarShortcutHints: Bool {
alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed
alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed
}
var body: some View {
// Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// (The titlebar controls don't otherwise re-render on UserDefaults changes.)
let _ = shortcutRefreshTick
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
@ -283,7 +283,7 @@ struct TitlebarControlsView: View {
.padding(.trailing, titlebarHintTrailingInset)
.background(
WindowAccessor { window in
commandKeyMonitor.setHostWindow(window)
modifierKeyMonitor.setHostWindow(window)
}
.frame(width: 0, height: 0)
)
@ -291,10 +291,10 @@ struct TitlebarControlsView: View {
shortcutRefreshTick &+= 1
}
.onAppear {
commandKeyMonitor.start()
modifierKeyMonitor.start()
}
.onDisappear {
commandKeyMonitor.stop()
modifierKeyMonitor.stop()
}
}
@ -320,8 +320,8 @@ struct TitlebarControlsView: View {
iconLabel(systemName: "sidebar.left", config: config)
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel("Toggle Sidebar")
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar"))
.accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"))
.safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -347,8 +347,8 @@ struct TitlebarControlsView: View {
}
.accessibilityIdentifier("titlebarControl.showNotifications")
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.accessibilityLabel("Notifications")
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
.accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"))
.safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -359,8 +359,8 @@ struct TitlebarControlsView: View {
iconLabel(systemName: "plus", config: config)
}
.accessibilityIdentifier("titlebarControl.newTab")
.accessibilityLabel("New Workspace")
.help(KeyboardShortcutSettings.Action.newTab.tooltip("New workspace"))
.accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"))
.safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
}
let paddedContent = content.padding(config.groupPadding)
@ -503,8 +503,8 @@ struct TitlebarControlsView: View {
}
@MainActor
private final class TitlebarCommandKeyMonitor: ObservableObject {
@Published private(set) var isCommandPressed = false
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
private weak var hostWindow: NSWindow?
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
SidebarCommandHintPolicy.isCurrentWindow(
ShortcutHintModifierPolicy.isCurrentWindow(
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: modifierFlags,
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func queueHintShow() {
guard !isCommandPressed else { return }
guard !isModifierPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: NSEvent.modifierFlags,
hostWindowNumber: self.hostWindow?.windowNumber,
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
eventWindowNumber: nil,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else { return }
self.isCommandPressed = true
self.isModifierPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isCommandPressed = false
isModifierPressed = false
}
}
@ -729,6 +729,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
// Prevent the titlebar accessory from clipping button backgrounds
// at the bottom edge (the system constrains accessory height to the
// titlebar, which can be slightly shorter than the button frames).
containerView.wantsLayer = true
containerView.layer?.masksToBounds = false
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
@ -901,11 +906,11 @@ private struct NotificationsPopoverView: View {
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Notifications")
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.headline)
Spacer()
if !notificationStore.notifications.isEmpty {
Button("Clear All") {
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
@ -921,9 +926,9 @@ private struct NotificationsPopoverView: View {
Image(systemName: "bell.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("No notifications yet")
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
.font(.headline)
Text("Desktop notifications will appear here.")
Text(String(localized: "notifications.empty.subtitle", defaultValue: "Desktop notifications will appear here."))
.font(.subheadline)
.foregroundColor(.secondary)
}

View file

@ -22,27 +22,29 @@ class UpdateViewModel: ObservableObject {
case .idle:
return ""
case .permissionRequest:
return "Enable Automatic Updates?"
return String(localized: "update.permissionRequest.text", defaultValue: "Enable Automatic Updates?")
case .checking:
return "Checking for Updates…"
return String(localized: "update.checking", defaultValue: "Checking for Updates…")
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
if !version.isEmpty {
return "Update Available: \(version)"
return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)")
}
return "Update Available"
return String(localized: "update.available.short", defaultValue: "Update Available")
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
return String(format: "Downloading: %.0f%%", progress * 100)
let percent = String(format: "%.0f%%", progress * 100)
return String(localized: "update.downloading.progress", defaultValue: "Downloading: \(percent)")
}
return "Downloading…"
return String(localized: "update.downloading.status", defaultValue: "Downloading…")
case .extracting(let extracting):
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
let percent = String(format: "%.0f%%", extracting.progress * 100)
return String(localized: "update.extracting.progress", defaultValue: "Preparing: \(percent)")
case .installing(let install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…"
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installing.status", defaultValue: "Installing…")
case .notFound:
return "No Updates Available"
return String(localized: "update.noUpdates.title", defaultValue: "No Updates Available")
case .error(let err):
return Self.userFacingErrorTitle(for: err.error)
}
@ -87,19 +89,19 @@ class UpdateViewModel: ObservableObject {
case .idle:
return ""
case .permissionRequest:
return "Configure automatic update preferences"
return String(localized: "update.configureAutoUpdates", defaultValue: "Configure automatic update preferences")
case .checking:
return "Please wait while we check for available updates"
return String(localized: "update.pleaseWait", defaultValue: "Please wait while we check for available updates")
case .updateAvailable(let update):
return update.releaseNotes?.label ?? "Download and install the latest version"
return update.releaseNotes?.label ?? String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version")
case .downloading:
return "Downloading the update package"
return String(localized: "update.downloadingPackage", defaultValue: "Downloading the update package")
case .extracting:
return "Extracting and preparing the update"
return String(localized: "update.preparingUpdate", defaultValue: "Extracting and preparing the update")
case let .installing(install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installingAndRestarting", defaultValue: "Installing update and preparing to restart")
case .notFound:
return "You are running the latest version"
return String(localized: "update.noUpdates.message", defaultValue: "You are running the latest version")
case .error(let err):
return Self.userFacingErrorMessage(for: err.error)
}
@ -177,21 +179,21 @@ class UpdateViewModel: ObservableObject {
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "No Internet Connection"
return String(localized: "update.error.noInternet.title", defaultValue: "No Internet Connection")
case NSURLErrorTimedOut:
return "Update Timed Out"
return String(localized: "update.error.timedOut.title", defaultValue: "Update Timed Out")
case NSURLErrorCannotFindHost:
return "Server Not Found"
return String(localized: "update.error.serverNotFound.title", defaultValue: "Server Not Found")
case NSURLErrorCannotConnectToHost:
return "Server Unreachable"
return String(localized: "update.error.serverUnreachable.title", defaultValue: "Server Unreachable")
case NSURLErrorNetworkConnectionLost:
return "Connection Lost"
return String(localized: "update.error.connectionLost.title", defaultValue: "Connection Lost")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "Secure Connection Failed"
return String(localized: "update.error.secureConnectionFailed.title", defaultValue: "Secure Connection Failed")
default:
break
}
@ -199,24 +201,24 @@ class UpdateViewModel: ObservableObject {
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 4005:
return "Updater Permission Error"
return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error")
case 2001:
return "Couldn't Download Update"
return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update")
case 1000, 1002:
return "Update Feed Error"
return String(localized: "update.error.feedError.title", defaultValue: "Update Feed Error")
case 4:
return "Invalid Update Feed"
return String(localized: "update.error.invalidFeed.title", defaultValue: "Invalid Update Feed")
case 3:
return "Insecure Update Feed"
return String(localized: "update.error.insecureFeed.title", defaultValue: "Insecure Update Feed")
case 1, 2, 3001, 3002:
return "Update Signature Error"
return String(localized: "update.error.signatureError.title", defaultValue: "Update Signature Error")
case 1003, 1005:
return "App Location Issue"
return String(localized: "update.error.appLocation.title", defaultValue: "App Location Issue")
default:
break
}
}
return "Update Failed"
return String(localized: "update.error.failed.title", defaultValue: "Update Failed")
}
static func userFacingErrorMessage(for error: Swift.Error) -> String {
@ -224,21 +226,21 @@ class UpdateViewModel: ObservableObject {
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "cmux cant reach the update server. Check your internet connection and try again."
return String(localized: "update.error.noInternet.message", defaultValue: "cmux cant reach the update server. Check your internet connection and try again.")
case NSURLErrorTimedOut:
return "The update server took too long to respond. Try again in a moment."
return String(localized: "update.error.timedOut.message", defaultValue: "The update server took too long to respond. Try again in a moment.")
case NSURLErrorCannotFindHost:
return "The update server cant be found. Check your connection or try again later."
return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server cant be found. Check your connection or try again later.")
case NSURLErrorCannotConnectToHost:
return "cmux couldnt connect to the update server. Check your connection or try again later."
return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldnt connect to the update server. Check your connection or try again later.")
case NSURLErrorNetworkConnectionLost:
return "The network connection was lost while checking for updates. Try again."
return String(localized: "update.error.connectionLost.message", defaultValue: "The network connection was lost while checking for updates. Try again.")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "A secure connection to the update server couldnt be established. Try again later."
return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldnt be established. Try again later.")
default:
break
}
@ -246,17 +248,17 @@ class UpdateViewModel: ObservableObject {
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 2001:
return "cmux couldn't download the update feed. Check your connection and try again."
return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.")
case 1000, 1002:
return "The update feed could not be read. Please try again later."
return String(localized: "update.error.feedRead.message", defaultValue: "The update feed could not be read. Please try again later.")
case 4:
return "The update feed URL is invalid. Please contact support."
return String(localized: "update.error.invalidFeed.message", defaultValue: "The update feed URL is invalid. Please contact support.")
case 3:
return "The update feed is insecure. Please contact support."
return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.")
case 1, 2, 3001, 3002:
return "The update's signature could not be verified. Please try again later."
return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.")
case 1003, 1005, 4005:
return "Move cmux into Applications and relaunch to enable updates."
return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.")
default:
break
}
@ -487,8 +489,8 @@ enum UpdateState: Equatable {
var label: String {
switch self {
case .commit: return "View GitHub Commit"
case .tagged: return "View Release Notes"
case .commit: return String(localized: "update.viewGitHubCommit", defaultValue: "View GitHub Commit")
case .tagged: return String(localized: "update.viewReleaseNotes", defaultValue: "View Release Notes")
}
}
}

View file

@ -218,6 +218,12 @@ func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
return false
}
/// Re-entrancy guard for the sibling hit-test walk. When `sibling.hitTest()`
/// triggers SwiftUI view-body evaluation, AppKit can call back into this
/// function before the outer invocation finishes, causing a Swift
/// exclusive-access violation (SIGABRT). Main-thread only, no lock needed.
private var _windowDragHandleIsResolvingSiblingHits = false
/// Returns whether the titlebar drag handle should capture a hit at `point`.
/// We only claim the hit when no sibling view already handles it, so interactive
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
@ -295,6 +301,20 @@ func windowDragHandleShouldCaptureHit(
return true
}
// Bail out if we're already inside a sibling hit-test walk. This happens
// when sibling.hitTest() re-enters SwiftUI layout, which calls hitTest on
// this drag handle again. Proceeding would trigger an exclusive-access
// violation in the Swift runtime.
guard !_windowDragHandleIsResolvingSiblingHits else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))")
#endif
return false
}
_windowDragHandleIsResolvingSiblingHits = true
defer { _windowDragHandleIsResolvingSiblingHits = false }
let siblingSnapshot = Array(superview.subviews.reversed())
#if DEBUG
@ -359,6 +379,13 @@ struct WindowDragHandleView: NSViewRepresentable {
override func hitTest(_ point: NSPoint) -> NSView? {
let currentEvent = NSApp.currentEvent
// Fast bail-out: only claim hits for left-mouse-down events.
// For mouseMoved / mouseEntered / etc., return nil immediately
// to avoid re-entering SwiftUI view state during layout passes,
// which causes exclusive-access crashes.
guard currentEvent?.type == .leftMouseDown else {
return nil
}
let shouldCapture = windowDragHandleShouldCaptureHit(
point,
in: self,

View file

@ -1888,6 +1888,7 @@ final class Workspace: Identifiable, ObservableObject {
let terminalSnapshot: SessionTerminalPanelSnapshot?
let browserSnapshot: SessionBrowserPanelSnapshot?
let markdownSnapshot: SessionMarkdownPanelSnapshot?
switch panel.panelType {
case .terminal:
guard let _ = panel as? TerminalPanel else { return nil }
@ -1901,6 +1902,7 @@ final class Workspace: Identifiable, ObservableObject {
scrollback: resolvedScrollback
)
browserSnapshot = nil
markdownSnapshot = nil
case .browser:
guard let browserPanel = panel as? BrowserPanel else { return nil }
terminalSnapshot = nil
@ -1913,6 +1915,12 @@ final class Workspace: Identifiable, ObservableObject {
backHistoryURLStrings: historySnapshot.backHistoryURLStrings,
forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings
)
markdownSnapshot = nil
case .markdown:
guard let mdPanel = panel as? MarkdownPanel else { return nil }
terminalSnapshot = nil
browserSnapshot = nil
markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath)
}
return SessionPanelSnapshot(
@ -1927,7 +1935,8 @@ final class Workspace: Identifiable, ObservableObject {
listeningPorts: listeningPorts,
ttyName: ttyName,
terminal: terminalSnapshot,
browser: browserSnapshot
browser: browserSnapshot,
markdown: markdownSnapshot
)
}
@ -2077,6 +2086,19 @@ final class Workspace: Identifiable, ObservableObject {
}
applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id)
return browserPanel.id
case .markdown:
guard let filePath = snapshot.markdown?.filePath else {
return nil
}
guard let markdownPanel = newMarkdownSurface(
inPane: paneId,
filePath: filePath,
focus: false
) else {
return nil
}
applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id)
return markdownPanel.id
}
}
@ -4815,21 +4837,34 @@ final class Workspace: Identifiable, ObservableObject {
private enum SurfaceKind {
static let terminal = "terminal"
static let browser = "browser"
static let markdown = "markdown"
}
// MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
BonsplitConfiguration.SplitButtonTooltips(
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down"))
)
}
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
bonsplitAppearance(from: config.backgroundColor)
bonsplitAppearance(
from: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity
)
}
static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
let themedColor = GhosttyBackgroundTheme.color(
backgroundColor: backgroundColor,
opacity: backgroundOpacity
)
let includeAlpha = themedColor.alphaComponent < 0.999
return themedColor.hexString(includeAlpha: includeAlpha)
}
nonisolated static func resolvedChromeColors(
@ -4838,25 +4873,62 @@ final class Workspace: Identifiable, ObservableObject {
.init(backgroundHex: backgroundColor.hexString())
}
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
let chromeColors = resolvedChromeColors(from: backgroundColor)
return BonsplitConfiguration.Appearance(
private static func bonsplitAppearance(
from backgroundColor: NSColor,
backgroundOpacity: Double
) -> BonsplitConfiguration.Appearance {
BonsplitConfiguration.Appearance(
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
chromeColors: chromeColors
chromeColors: .init(
backgroundHex: Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
)
)
}
func applyGhosttyChrome(from config: GhosttyConfig) {
applyGhosttyChrome(backgroundColor: config.backgroundColor)
func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
applyGhosttyChrome(
backgroundColor: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity,
reason: reason
)
}
func applyGhosttyChrome(backgroundColor: NSColor) {
let nextHex = backgroundColor.hexString()
if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex {
func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") {
let nextHex = Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
let currentChromeColors = bonsplitController.configuration.appearance.chromeColors
let isNoOp = currentChromeColors.backgroundHex == nextHex
if GhosttyApp.shared.backgroundLogEnabled {
let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil"
GhosttyApp.shared.logBackground(
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)"
)
}
if isNoOp {
return
}
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")"
)
}
}
func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") {
applyGhosttyChrome(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundColor.alphaComponent,
reason: reason
)
}
init(
@ -4882,7 +4954,12 @@ final class Workspace: Identifiable, ObservableObject {
// Configure bonsplit with keepAllAlive to preserve terminal state
// and keep split entry instantaneous.
let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load())
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
// runs for socket/CLI workspace creation and can cause visible typing lag.
let appearance = Self.bonsplitAppearance(
from: GhosttyApp.shared.defaultBackgroundColor,
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity
)
let config = BonsplitConfiguration(
allowSplits: true,
allowCloseTabs: true,
@ -5079,6 +5156,30 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
let subscription = markdownPanel.$displayTitle
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self, weak markdownPanel] newTitle in
guard let self = self,
let markdownPanel = markdownPanel,
let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return }
guard let existing = self.bonsplitController.tab(tabId) else { return }
if self.panelTitles[markdownPanel.id] != newTitle {
self.panelTitles[markdownPanel.id] = newTitle
}
let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle)
guard existing.title != resolvedTitle else { return }
self.bonsplitController.updateTab(
tabId,
title: resolvedTitle,
hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil
)
}
panelSubscriptions[markdownPanel.id] = subscription
}
// MARK: - Panel Access
func panel(for surfaceId: TabID) -> (any Panel)? {
@ -5094,12 +5195,18 @@ final class Workspace: Identifiable, ObservableObject {
panels[panelId] as? BrowserPanel
}
func markdownPanel(for panelId: UUID) -> MarkdownPanel? {
panels[panelId] as? MarkdownPanel
}
private func surfaceKind(for panel: any Panel) -> String {
switch panel.panelType {
case .terminal:
return SurfaceKind.terminal
case .browser:
return SurfaceKind.browser
case .markdown:
return SurfaceKind.markdown
}
}
@ -5210,6 +5317,18 @@ final class Workspace: Identifiable, ObservableObject {
return surfaceKind(for: panel)
}
func requestBackgroundTerminalSurfaceStartIfNeeded() {
for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
func hasLoadedTerminalSurface() -> Bool {
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
guard !terminalPanels.isEmpty else { return true }
return terminalPanels.contains { $0.surface.surface != nil }
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
@ -5838,11 +5957,21 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = sourcePaneId else { return nil }
// Inherit working directory: prefer the source panel's reported cwd,
// fall back to the workspace's current directory.
let splitWorkingDirectory: String? = panelDirectories[panelId]
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil : currentDirectory)
#if DEBUG
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")")
#endif
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: splitWorkingDirectory,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
@ -6097,15 +6226,166 @@ final class Workspace: Identifiable, ObservableObject {
return browserPanel
}
// MARK: - Markdown Panel Creation
/// Create a new markdown panel split from an existing panel.
func newMarkdownSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
filePath: String,
focus: Bool = true
) -> MarkdownPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
// Create markdown panel
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false
)
surfaceIdToPanelId[newTab.id] = markdownPanel.id
let previousFocusedPanelId = focusedPanelId
// Create the split with the markdown tab already present in the new pane.
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
surfaceIdToPanelId.removeValue(forKey: newTab.id)
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
// Suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(markdownPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: markdownPanel.id,
previousHostedView: previousHostedView
)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
/// Create a new markdown surface (tab) in the specified pane.
@discardableResult
func newMarkdownSurface(
inPane paneId: PaneID,
filePath: String,
focus: Bool? = nil
) -> MarkdownPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
guard let newTabId = bonsplitController.createTab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = markdownPanel.id
// Match terminal behavior: enforce deterministic selection + focus.
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
applyTabSelection(tabId: newTabId, inPane: paneId)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
/// Tear down all panels in this workspace, freeing their Ghostty surfaces.
/// Called before the workspace is removed from TabManager to ensure child
/// processes receive SIGHUP even if ARC deallocation is delayed.
func teardownAllPanels() {
let panelEntries = Array(panels)
for (panelId, panel) in panelEntries {
panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}
panels.removeAll(keepingCapacity: false)
surfaceIdToPanelId.removeAll(keepingCapacity: false)
panelSubscriptions.removeAll(keepingCapacity: false)
pruneSurfaceMetadata(validSurfaceIds: [])
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
lastTerminalConfigInheritancePanelId = nil
lastTerminalConfigInheritanceFontPoints = nil
}
/// Close a panel.
/// Returns true when a bonsplit tab close request was issued.
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
#if DEBUG
let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId)
dlog(
"surface.close.request panel=\(panelId.uuidString.prefix(5)) " +
"force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
if let tabId = surfaceIdFromPanelId(panelId) {
if force {
forceCloseTabIds.insert(tabId)
}
// Close the tab in bonsplit (this triggers delegate callback)
return bonsplitController.closeTab(tabId)
let closed = bonsplitController.closeTab(tabId)
#if DEBUG
dlog(
"surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)"
)
#endif
return closed
}
// Mapping can transiently drift during split-tree mutations. If the target panel is
@ -6137,12 +6417,38 @@ final class Workspace: Identifiable, ObservableObject {
dlog(
"surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " +
"selectedTab=\(String(describing: selected.id).prefix(5)) " +
"closed=\(closed ? 1 : 0)"
"closed=\(closed ? 1 : 0) " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
return closed
}
#if DEBUG
private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String {
guard let panel else { return "panelState=missing" }
if let terminal = panel as? TerminalPanel {
let hosted = terminal.hostedView
let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height)
let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height)
let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0
return
"panelState=terminal panel=\(panelId.uuidString.prefix(5)) " +
"surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " +
"inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " +
"hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
}
if let browser = panel as? BrowserPanel {
let webView = browser.webView
let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height)
return
"panelState=browser panel=\(panelId.uuidString.prefix(5)) " +
"webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)"
}
return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))"
}
#endif
func paneId(forPanelId panelId: UUID) -> PaneID? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
return bonsplitController.allPaneIds.first { paneId in
@ -6747,6 +7053,51 @@ final class Workspace: Identifiable, ObservableObject {
if let targetPaneId {
applyTabSelection(tabId: tabId, inPane: targetPaneId)
}
if let browserPanel = panels[panelId] as? BrowserPanel {
// Keep browser find focus behavior aligned with terminal find behavior.
// When switching back to a pane with an already-open find bar, reassert
// focus to that field instead of leaving first responder stale.
if browserPanel.searchState != nil {
browserPanel.startFind()
} else {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
}
}
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
_ browserPanel: BrowserPanel,
trigger: FocusPanelTrigger
) {
guard trigger == .standard else { return }
guard !isCommandPaletteVisibleForWorkspaceWindow() else { return }
guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return }
guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return }
_ = browserPanel.requestAddressBarFocus()
NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id)
}
private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool {
guard let app = AppDelegate.shared else {
return false
}
if let manager = app.tabManagerFor(tabId: id),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
func moveFocus(direction: NavigationDirection) {
@ -6875,7 +7226,7 @@ final class Workspace: Identifiable, ObservableObject {
if requiresSplit && !isSplit {
return
}
terminalPanel.triggerFlash()
terminalPanel.triggerNotificationDismissFlash()
}
func triggerDebugFlash(panelId: UUID) {
@ -6895,6 +7246,19 @@ final class Workspace: Identifiable, ObservableObject {
}
}
/// Hide all browser portal views for this workspace.
/// Called before the workspace is unmounted so a portal-hosted WKWebView
/// cannot remain visible after this workspace stops being selected.
func hideAllBrowserPortalViews() {
for panel in panels.values {
guard let browser = panel as? BrowserPanel else { continue }
BrowserWindowPortalRegistry.hide(
webView: browser.webView,
source: "workspaceRetire"
)
}
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)
@ -7029,11 +7393,11 @@ final class Workspace: Identifiable, ObservableObject {
needsFollowUpPass = true
}
hostedView.reconcileGeometryNow()
let geometryChanged = hostedView.reconcileGeometryNow()
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
// layout and view lifecycle changes that free surfaces (#432).
if terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh()
if geometryChanged, terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile")
}
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
@ -7088,9 +7452,9 @@ final class Workspace: Identifiable, ObservableObject {
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh()
let geometryChanged = panel.hostedView.reconcileGeometryNow()
if geometryChanged, panel.surface.surface != nil {
panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh")
}
if panel.surface.surface == nil {
panel.surface.requestBackgroundSurfaceStartIfNeeded()
@ -7157,15 +7521,15 @@ final class Workspace: Identifiable, ObservableObject {
let panel = panels[panelId] else { return }
let alert = NSAlert()
alert.messageText = "Rename Tab"
alert.informativeText = "Enter a custom name for this tab."
alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab")
alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.")
let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle
let input = NSTextField(string: currentTitle)
input.placeholderString = "Tab name"
input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name")
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
@ -7177,6 +7541,146 @@ final class Workspace: Identifiable, ObservableObject {
setPanelCustomTitle(panelId: panelId, title: input.stringValue)
}
private enum PanelMoveDestination {
case newWorkspaceInCurrentWindow
case selectedWorkspaceInNewWindow
case existingWorkspace(UUID)
}
private func promptMovePanel(tabId: TabID) {
guard let panelId = panelIdFromSurfaceId(tabId),
let app = AppDelegate.shared else { return }
let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) }
let workspaceTargets = app.workspaceMoveTargets(
excludingWorkspaceId: id,
referenceWindowId: currentWindowId
)
var options: [(title: String, destination: PanelMoveDestination)] = [
(String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow),
(String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow),
]
options.append(contentsOf: workspaceTargets.map { target in
(target.label, .existingWorkspace(target.workspaceId))
})
let alert = NSAlert()
alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab")
alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.")
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
for option in options {
popup.addItem(withTitle: option.title)
}
popup.selectItem(at: 0)
alert.accessoryView = popup
alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
guard alert.runModal() == .alertFirstButtonReturn else { return }
let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1))
let destination = options[selectedIndex].destination
let moved: Bool
switch destination {
case .newWorkspaceInCurrentWindow:
guard let manager = app.tabManagerFor(tabId: id) else { return }
let workspace = manager.addWorkspace(select: true)
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspace.id,
focus: true,
focusWindow: false
)
case .selectedWorkspaceInNewWindow:
let newWindowId = app.createMainWindow()
guard let destinationManager = app.tabManagerFor(windowId: newWindowId),
let destinationWorkspaceId = destinationManager.selectedTabId else {
return
}
moved = app.moveSurface(
panelId: panelId,
toWorkspace: destinationWorkspaceId,
focus: true,
focusWindow: true
)
if !moved {
_ = app.closeMainWindow(windowId: newWindowId)
}
case .existingWorkspace(let workspaceId):
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspaceId,
focus: true,
focusWindow: true
)
}
if !moved {
let failure = NSAlert()
failure.alertStyle = .warning
failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed")
failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.")
failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
_ = failure.runModal()
}
}
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
guard let app = AppDelegate.shared else { return false }
#if DEBUG
let dropStart = ProcessInfo.processInfo.systemUptime
#endif
let targetPane: PaneID
let targetIndex: Int?
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
#if DEBUG
let destinationLabel: String
#endif
switch request.destination {
case .insert(let paneId, let index):
targetPane = paneId
targetIndex = index
splitTarget = nil
#if DEBUG
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
#endif
case .split(let paneId, let orientation, let insertFirst):
targetPane = paneId
targetIndex = nil
splitTarget = (orientation, insertFirst)
#if DEBUG
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
#endif
}
#if DEBUG
dlog(
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
)
#endif
let moved = app.moveBonsplitTab(
tabId: request.tabId.uuid,
toWorkspace: id,
targetPane: targetPane,
targetIndex: targetIndex,
splitTarget: splitTarget,
focus: true,
focusWindow: true
)
#if DEBUG
dlog(
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
)
#endif
return moved
}
}
// MARK: - BonsplitDelegate
@ -7185,11 +7689,11 @@ 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.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
@ -7383,7 +7887,11 @@ extension Workspace: BonsplitDelegate {
// Clean up our panel
guard let panelId = panelIdFromSurfaceId(tabId) else {
#if DEBUG
NSLog("[Workspace] didCloseTab: no panelId for tabId")
dlog(
"surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " +
"panels=\(panels.count) panes=\(controller.allPaneIds.count)"
)
#endif
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
@ -7391,12 +7899,15 @@ extension Workspace: BonsplitDelegate {
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 DEBUG
dlog(
"surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panel))"
)
#endif
if isDetaching, let panel {
let browserPanel = panel as? BrowserPanel
@ -7441,6 +7952,13 @@ extension Workspace: BonsplitDelegate {
if panels.isEmpty {
if isDetaching {
gitBranch = nil
#if DEBUG
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace"
)
#endif
scheduleTerminalGeometryReconcile()
return
}
let replacement = createReplacementTerminalPanel()
@ -7453,6 +7971,13 @@ extension Workspace: BonsplitDelegate {
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
#if DEBUG
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " +
"replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)"
)
#endif
return
}
@ -7470,6 +7995,16 @@ extension Workspace: BonsplitDelegate {
normalizePinnedTabs(in: pane)
}
refreshFocusedGitBranchState()
#if DEBUG
let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " +
"focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)"
)
#endif
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
@ -7512,35 +8047,55 @@ extension Workspace: BonsplitDelegate {
}
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
_ = paneId
let liveTabIds: Set<TabID> = Set(
controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) }
let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? []
let shouldScheduleFocusReconcile = !isDetachingCloseTransaction
#if DEBUG
dlog(
"surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " +
"closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)"
)
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 {
#endif
if !closedPanelIds.isEmpty {
for panelId in closedPanelIds {
#if DEBUG
dlog(
"surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
panels[panelId]?.close()
panels.removeValue(forKey: panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
}
let closedSet = Set(closedPanelIds)
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
recomputeListeningPorts()
}
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
if shouldScheduleFocusReconcile {
scheduleFocusReconcile()
}
#if DEBUG
dlog(
"surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " +
"remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)"
)
#endif
}
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool {

View file

@ -53,12 +53,52 @@ struct WorkspaceContentView: View {
}()
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
panelView(
tab: tab,
paneId: paneId,
isSplit: isSplit,
appearance: appearance
)
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
paneId: paneId,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
// Keep bonsplit focus in sync with the AppKit first responder for the
// active workspace. This prevents divergence between the blue focused-tab
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
}
} emptyPane: { paneId in
// Empty pane content
EmptyPanelView(workspace: workspace, paneId: paneId)
@ -104,55 +144,6 @@ struct WorkspaceContentView: View {
}
}
@ViewBuilder
private func panelView(
tab: Bonsplit.Tab,
paneId: PaneID,
isSplit: Bool,
appearance: PanelAppearance
) -> some View {
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
EmptyPanelView(workspace: workspace, paneId: paneId)
}
}
private func syncBonsplitNotificationBadges() {
let unreadFromNotifications: Set<UUID> = Set(
notificationStore.notifications
@ -187,7 +178,8 @@ struct WorkspaceContentView: View {
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,
loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() },
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor },
defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity }
) -> GhosttyConfig {
var next = loadConfig()
let loadedBackgroundHex = next.backgroundColor.hexString()
@ -204,9 +196,12 @@ struct WorkspaceContentView: View {
}
next.backgroundColor = resolvedBackground
// Use the runtime opacity from the Ghostty engine, which may differ from the
// file-level value parsed by GhosttyConfig.load().
next.backgroundOpacity = defaultBackgroundOpacity()
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) theme=\(next.theme ?? "nil")"
)
}
return next
@ -228,7 +223,8 @@ struct WorkspaceContentView: View {
let sourceLabel = backgroundSource ?? "nil"
let payloadLabel = notificationPayloadHex ?? "nil"
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
let opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001
let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || reason == "onAppear"
logTheme(
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
)
@ -253,8 +249,7 @@ struct WorkspaceContentView: View {
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
_ = chromeReason
workspace.applyGhosttyChrome(from: next)
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(
@ -411,7 +406,7 @@ struct EmptyPanelView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
.background(Color(nsColor: GhosttyBackgroundTheme.currentColor()))
#if DEBUG
.onAppear {
DebugUIEventCounters.emptyPanelAppearCount += 1

File diff suppressed because it is too large Load diff