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:
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
207
Sources/Find/BrowserFindJavaScript.swift
Normal file
207
Sources/Find/BrowserFindJavaScript.swift
Normal 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
|
||||
}
|
||||
}
|
||||
193
Sources/Find/BrowserSearchOverlay.swift
Normal file
193
Sources/Find/BrowserSearchOverlay.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: ""
|
||||
)
|
||||
|
|
|
|||
182
Sources/Panels/MarkdownPanel.swift
Normal file
182
Sources/Panels/MarkdownPanel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
355
Sources/Panels/MarkdownPanelView.swift
Normal file
355
Sources/Panels/MarkdownPanelView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
public enum PanelType: String, Codable, Sendable {
|
||||
case terminal
|
||||
case browser
|
||||
case markdown
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 can’t reach the update server. Check your internet connection and try again."
|
||||
return String(localized: "update.error.noInternet.message", defaultValue: "cmux can’t 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 can’t be found. Check your connection or try again later."
|
||||
return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server can’t be found. Check your connection or try again later.")
|
||||
case NSURLErrorCannotConnectToHost:
|
||||
return "cmux couldn’t connect to the update server. Check your connection or try again later."
|
||||
return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldn’t 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 couldn’t be established. Try again later."
|
||||
return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldn’t 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue