Merge remote-tracking branch 'origin/main' into task-white-rect-frame-cmd-d-ctrl-d
# Conflicts: # Sources/AppDelegate.swift # Sources/TerminalWindowPortal.swift
This commit is contained in:
commit
638801cce8
17 changed files with 2194 additions and 440 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -7,6 +7,15 @@ on:
|
|||
pull_request:
|
||||
|
||||
jobs:
|
||||
workflow-guard-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate self-hosted runner guards
|
||||
run: ./tests/test_ci_self_hosted_guard.sh
|
||||
|
||||
web-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
|
|
@ -26,6 +35,8 @@ jobs:
|
|||
run: bun tsc --noEmit
|
||||
|
||||
ui-tests:
|
||||
# Never run self-hosted jobs for fork pull requests.
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: self-hosted
|
||||
concurrency:
|
||||
group: self-hosted-build
|
||||
|
|
|
|||
|
|
@ -36,6 +36,188 @@ enum FinderServicePathResolver {
|
|||
}
|
||||
}
|
||||
|
||||
enum TerminalDirectoryOpenTarget: String, CaseIterable {
|
||||
case vscode
|
||||
case cursor
|
||||
case windsurf
|
||||
case antigravity
|
||||
case finder
|
||||
case terminal
|
||||
case iterm2
|
||||
case ghostty
|
||||
case warp
|
||||
case xcode
|
||||
case androidStudio
|
||||
case zed
|
||||
|
||||
struct DetectionEnvironment {
|
||||
let homeDirectoryPath: String
|
||||
let fileExistsAtPath: (String) -> Bool
|
||||
|
||||
static let live = DetectionEnvironment(
|
||||
homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path,
|
||||
fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
static var commandPaletteShortcutTargets: [Self] {
|
||||
Array(allCases)
|
||||
}
|
||||
|
||||
static func availableTargets(in environment: DetectionEnvironment = .live) -> Set<Self> {
|
||||
Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) })
|
||||
}
|
||||
|
||||
static let cachedLiveAvailableTargets: Set<Self> = availableTargets(in: .live)
|
||||
|
||||
var commandPaletteCommandId: String {
|
||||
"palette.terminalOpenDirectory.\(rawValue)"
|
||||
}
|
||||
|
||||
var commandPaletteTitle: String {
|
||||
switch self {
|
||||
case .vscode:
|
||||
return "Open Current Directory in VS Code"
|
||||
case .cursor:
|
||||
return "Open Current Directory in Cursor"
|
||||
case .windsurf:
|
||||
return "Open Current Directory in Windsurf"
|
||||
case .antigravity:
|
||||
return "Open Current Directory in Antigravity"
|
||||
case .finder:
|
||||
return "Open Current Directory in Finder"
|
||||
case .terminal:
|
||||
return "Open Current Directory in Terminal"
|
||||
case .iterm2:
|
||||
return "Open Current Directory in iTerm2"
|
||||
case .ghostty:
|
||||
return "Open Current Directory in Ghostty"
|
||||
case .warp:
|
||||
return "Open Current Directory in Warp"
|
||||
case .xcode:
|
||||
return "Open Current Directory in Xcode"
|
||||
case .androidStudio:
|
||||
return "Open Current Directory in Android Studio"
|
||||
case .zed:
|
||||
return "Open Current Directory in Zed"
|
||||
}
|
||||
}
|
||||
|
||||
var commandPaletteKeywords: [String] {
|
||||
let common = ["terminal", "directory", "open", "ide"]
|
||||
switch self {
|
||||
case .vscode:
|
||||
return common + ["vs", "code", "visual", "studio"]
|
||||
case .cursor:
|
||||
return common + ["cursor"]
|
||||
case .windsurf:
|
||||
return common + ["windsurf"]
|
||||
case .antigravity:
|
||||
return common + ["antigravity"]
|
||||
case .finder:
|
||||
return common + ["finder", "file", "manager", "reveal"]
|
||||
case .terminal:
|
||||
return common + ["terminal", "shell"]
|
||||
case .iterm2:
|
||||
return common + ["iterm", "iterm2", "terminal", "shell"]
|
||||
case .ghostty:
|
||||
return common + ["ghostty", "terminal", "shell"]
|
||||
case .warp:
|
||||
return common + ["warp", "terminal", "shell"]
|
||||
case .xcode:
|
||||
return common + ["xcode", "apple"]
|
||||
case .androidStudio:
|
||||
return common + ["android", "studio"]
|
||||
case .zed:
|
||||
return common + ["zed"]
|
||||
}
|
||||
}
|
||||
|
||||
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
|
||||
applicationPath(in: environment) != nil
|
||||
}
|
||||
|
||||
func applicationURL(in environment: DetectionEnvironment = .live) -> URL? {
|
||||
guard let path = applicationPath(in: environment) else { return nil }
|
||||
return URL(fileURLWithPath: path, isDirectory: true)
|
||||
}
|
||||
|
||||
private func applicationPath(in environment: DetectionEnvironment) -> String? {
|
||||
for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) {
|
||||
return path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] {
|
||||
let globalPrefix = "/Applications/"
|
||||
let userPrefix = "\(environment.homeDirectoryPath)/Applications/"
|
||||
var expanded: [String] = []
|
||||
|
||||
for candidate in applicationBundlePathCandidates {
|
||||
expanded.append(candidate)
|
||||
if candidate.hasPrefix(globalPrefix) {
|
||||
let suffix = String(candidate.dropFirst(globalPrefix.count))
|
||||
expanded.append(userPrefix + suffix)
|
||||
}
|
||||
}
|
||||
|
||||
return uniquePreservingOrder(expanded)
|
||||
}
|
||||
|
||||
private var applicationBundlePathCandidates: [String] {
|
||||
switch self {
|
||||
case .vscode:
|
||||
return [
|
||||
"/Applications/Visual Studio Code.app",
|
||||
"/Applications/Code.app",
|
||||
]
|
||||
case .cursor:
|
||||
return [
|
||||
"/Applications/Cursor.app",
|
||||
"/Applications/Cursor Preview.app",
|
||||
"/Applications/Cursor Nightly.app",
|
||||
]
|
||||
case .windsurf:
|
||||
return ["/Applications/Windsurf.app"]
|
||||
case .antigravity:
|
||||
return ["/Applications/Antigravity.app"]
|
||||
case .finder:
|
||||
return ["/System/Library/CoreServices/Finder.app"]
|
||||
case .terminal:
|
||||
return ["/System/Applications/Utilities/Terminal.app"]
|
||||
case .iterm2:
|
||||
return [
|
||||
"/Applications/iTerm.app",
|
||||
"/Applications/iTerm2.app",
|
||||
]
|
||||
case .ghostty:
|
||||
return ["/Applications/Ghostty.app"]
|
||||
case .warp:
|
||||
return ["/Applications/Warp.app"]
|
||||
case .xcode:
|
||||
return ["/Applications/Xcode.app"]
|
||||
case .androidStudio:
|
||||
return ["/Applications/Android Studio.app"]
|
||||
case .zed:
|
||||
return [
|
||||
"/Applications/Zed.app",
|
||||
"/Applications/Zed Preview.app",
|
||||
"/Applications/Zed Nightly.app",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func uniquePreservingOrder(_ paths: [String]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var deduped: [String] = []
|
||||
for path in paths where seen.insert(path).inserted {
|
||||
deduped.append(path)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspaceShortcutMapper {
|
||||
/// Maps Cmd+digit workspace shortcuts to a zero-based workspace index.
|
||||
/// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace.
|
||||
|
|
@ -70,10 +252,9 @@ func browserOmnibarSelectionDeltaForCommandNavigation(
|
|||
chars: String
|
||||
) -> Int? {
|
||||
guard hasFocusedAddressBar else { return nil }
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
guard normalizedFlags == [.control] else { return nil }
|
||||
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
|
||||
let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control]
|
||||
guard isCommandOrControlOnly else { return nil }
|
||||
if chars == "n" { return 1 }
|
||||
if chars == "p" { return -1 }
|
||||
return nil
|
||||
|
|
@ -85,9 +266,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
|
|||
keyCode: UInt16
|
||||
) -> Int? {
|
||||
guard hasFocusedAddressBar else { return nil }
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
|
||||
guard normalizedFlags == [] else { return nil }
|
||||
switch keyCode {
|
||||
case 125: return 1
|
||||
|
|
@ -96,10 +275,14 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
|
||||
let normalizedFlags = flags
|
||||
func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags {
|
||||
flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
}
|
||||
|
||||
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
|
||||
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
|
||||
return normalizedFlags == [] || normalizedFlags == [.shift]
|
||||
}
|
||||
|
||||
|
|
@ -204,6 +387,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
|
|||
return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil
|
||||
}
|
||||
|
||||
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
|
||||
guard let responder else { return nil }
|
||||
if let ghosttyView = responder as? GhosttyNSView {
|
||||
return ghosttyView
|
||||
}
|
||||
|
||||
if let view = responder as? NSView,
|
||||
let ghosttyView = cmuxOwningGhosttyView(for: view) {
|
||||
return ghosttyView
|
||||
}
|
||||
|
||||
if let textView = responder as? NSTextView,
|
||||
let delegateView = textView.delegate as? NSView,
|
||||
let ghosttyView = cmuxOwningGhosttyView(for: delegateView) {
|
||||
return ghosttyView
|
||||
}
|
||||
|
||||
var current = responder.nextResponder
|
||||
while let next = current {
|
||||
if let ghosttyView = next as? GhosttyNSView {
|
||||
return ghosttyView
|
||||
}
|
||||
if let view = next as? NSView,
|
||||
let ghosttyView = cmuxOwningGhosttyView(for: view) {
|
||||
return ghosttyView
|
||||
}
|
||||
current = next.nextResponder
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? {
|
||||
if let ghosttyView = view as? GhosttyNSView {
|
||||
return ghosttyView
|
||||
}
|
||||
|
||||
var current: NSView? = view.superview
|
||||
while let candidate = current {
|
||||
if let ghosttyView = candidate as? GhosttyNSView {
|
||||
return ghosttyView
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func browserZoomShortcutTraceCandidate(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
|
|
@ -603,6 +834,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
sidebarSelectionState: SidebarSelectionState
|
||||
) {
|
||||
let key = ObjectIdentifier(window)
|
||||
#if DEBUG
|
||||
let priorManagerToken = debugManagerToken(self.tabManager)
|
||||
#endif
|
||||
if let existing = mainWindowContexts[key] {
|
||||
existing.window = window
|
||||
} else {
|
||||
|
|
@ -626,6 +860,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
commandPaletteSelectionByWindowId[windowId] = 0
|
||||
commandPaletteSnapshotByWindowId[windowId] = .empty
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())"
|
||||
)
|
||||
#endif
|
||||
if window.isKeyWindow {
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
|
|
@ -855,6 +1094,117 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return mainWindowContexts[ObjectIdentifier(window)]
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func debugManagerToken(_ manager: TabManager?) -> String {
|
||||
guard let manager else { return "nil" }
|
||||
return String(describing: Unmanaged.passUnretained(manager).toOpaque())
|
||||
}
|
||||
|
||||
private func debugWindowToken(_ window: NSWindow?) -> String {
|
||||
guard let window else { return "nil" }
|
||||
let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none"
|
||||
let ident = window.identifier?.rawValue ?? "nil"
|
||||
let shortIdent: String
|
||||
if ident.count > 120 {
|
||||
shortIdent = String(ident.prefix(120)) + "..."
|
||||
} else {
|
||||
shortIdent = ident
|
||||
}
|
||||
return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)"
|
||||
}
|
||||
|
||||
private func debugContextToken(_ context: MainWindowContext?) -> String {
|
||||
guard let context else { return "nil" }
|
||||
let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0
|
||||
return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)"
|
||||
}
|
||||
|
||||
private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String {
|
||||
let activeManager = tabManager
|
||||
let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil"
|
||||
let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
|
||||
let contexts = mainWindowContexts.values
|
||||
.map { context in
|
||||
let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-"
|
||||
let window = context.window ?? windowForMainWindowId(context.windowId)
|
||||
let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}"
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
|
||||
let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil"
|
||||
let eventWindow = event?.window
|
||||
return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]"
|
||||
}
|
||||
#endif
|
||||
|
||||
private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? {
|
||||
if let window = event.window, isMainTerminalWindow(window) {
|
||||
return window
|
||||
}
|
||||
let eventWindowNumber = event.windowNumber
|
||||
if eventWindowNumber > 0,
|
||||
let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber),
|
||||
isMainTerminalWindow(numberedWindow) {
|
||||
return numberedWindow
|
||||
}
|
||||
if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) {
|
||||
return keyWindow
|
||||
}
|
||||
if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) {
|
||||
return mainWindow
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Re-sync app-level active window pointers from the currently focused main terminal window.
|
||||
/// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts.
|
||||
@discardableResult
|
||||
func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? {
|
||||
let (context, source): (MainWindowContext?, String) = {
|
||||
if let preferredWindow,
|
||||
let context = contextForMainWindow(preferredWindow) {
|
||||
return (context, "preferredWindow")
|
||||
}
|
||||
if let context = contextForMainWindow(NSApp.keyWindow) {
|
||||
return (context, "keyWindow")
|
||||
}
|
||||
if let context = contextForMainWindow(NSApp.mainWindow) {
|
||||
return (context, "mainWindow")
|
||||
}
|
||||
if let activeManager = tabManager,
|
||||
let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
|
||||
return (activeContext, "activeManager")
|
||||
}
|
||||
return (mainWindowContexts.values.first, "firstContextFallback")
|
||||
}()
|
||||
|
||||
#if DEBUG
|
||||
let beforeManagerToken = debugManagerToken(tabManager)
|
||||
dlog(
|
||||
"shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())"
|
||||
)
|
||||
#endif
|
||||
guard let context else { return tabManager }
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
} else {
|
||||
tabManager = context.tabManager
|
||||
sidebarState = context.sidebarState
|
||||
sidebarSelectionState = context.sidebarSelectionState
|
||||
TerminalController.shared.setActiveTabManager(context.tabManager)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())"
|
||||
)
|
||||
#endif
|
||||
return context.tabManager
|
||||
}
|
||||
|
||||
private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? {
|
||||
if let context = contextForMainWindow(event.window) {
|
||||
return context
|
||||
|
|
@ -865,13 +1215,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
if let context = contextForMainWindow(NSApp.mainWindow) {
|
||||
return context
|
||||
}
|
||||
if let activeManager = tabManager,
|
||||
let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
|
||||
return activeContext
|
||||
}
|
||||
return mainWindowContexts.values.first
|
||||
}
|
||||
|
||||
private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) {
|
||||
guard let context = preferredMainWindowContextForShortcuts(event: event),
|
||||
let window = context.window ?? windowForMainWindowId(context.windowId) else { return }
|
||||
setActiveMainWindow(window)
|
||||
let preferredWindow = mainWindowForShortcutEvent(event)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))"
|
||||
)
|
||||
#endif
|
||||
_ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -1849,7 +2212,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
|
||||
}
|
||||
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
|
||||
dlog(
|
||||
"monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))"
|
||||
)
|
||||
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
|
||||
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
|
||||
}
|
||||
|
|
@ -2176,7 +2541,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// When the terminal has active IME composition (e.g. Korean, Japanese, Chinese
|
||||
// input), don't intercept key events — let them flow through to the input method.
|
||||
if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView,
|
||||
if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder),
|
||||
ghosttyView.hasMarkedText() {
|
||||
return false
|
||||
}
|
||||
|
|
@ -2221,7 +2586,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// (e.g., split that doesn't properly blur the address bar). If the first responder
|
||||
// is a terminal surface, the address bar can't be focused.
|
||||
if browserAddressBarFocusedPanelId != nil,
|
||||
NSApp.keyWindow?.firstResponder is GhosttyNSView {
|
||||
cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil {
|
||||
#if DEBUG
|
||||
dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId")
|
||||
#endif
|
||||
|
|
@ -2258,6 +2623,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) {
|
||||
#if DEBUG
|
||||
dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))")
|
||||
#endif
|
||||
// Cmd+N semantics:
|
||||
// - If there are no main windows, create a new window.
|
||||
// - Otherwise, create a new workspace in the active window.
|
||||
|
|
@ -2368,6 +2736,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let manager = tabManager,
|
||||
let num = Int(chars),
|
||||
let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))"
|
||||
)
|
||||
#endif
|
||||
manager.selectTab(at: targetIndex)
|
||||
return true
|
||||
}
|
||||
|
|
@ -2436,6 +2809,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Split actions: Cmd+D / Cmd+Shift+D
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) {
|
||||
#if DEBUG
|
||||
dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))")
|
||||
#endif
|
||||
if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -2444,6 +2820,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) {
|
||||
#if DEBUG
|
||||
dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))")
|
||||
#endif
|
||||
if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -2678,10 +3057,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
chars: String
|
||||
) -> Bool {
|
||||
guard browserAddressBarFocusedPanelId != nil else { return false }
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
guard normalizedFlags == [.control] else { return false }
|
||||
let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags)
|
||||
let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control]
|
||||
guard isCommandOrControlOnly else { return false }
|
||||
return chars == "n" || chars == "p"
|
||||
}
|
||||
|
||||
|
|
@ -2867,6 +3245,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@discardableResult
|
||||
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
_ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
|
||||
|
||||
let directionLabel: String
|
||||
switch direction {
|
||||
case .left: directionLabel = "left"
|
||||
|
|
@ -2932,6 +3312,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@discardableResult
|
||||
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
_ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow)
|
||||
|
||||
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
|
||||
_ = focusBrowserAddressBar(panelId: panelId)
|
||||
return true
|
||||
|
|
@ -3296,10 +3678,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func setActiveMainWindow(_ window: NSWindow) {
|
||||
guard isMainTerminalWindow(window) else { return }
|
||||
guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return }
|
||||
#if DEBUG
|
||||
let beforeManagerToken = debugManagerToken(tabManager)
|
||||
#endif
|
||||
tabManager = context.tabManager
|
||||
sidebarState = context.sidebarState
|
||||
sidebarSelectionState = context.sidebarSelectionState
|
||||
TerminalController.shared.setActiveTabManager(context.tabManager)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func unregisterMainWindow(_ window: NSWindow) {
|
||||
|
|
@ -3342,6 +3732,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
private func isMainTerminalWindow(_ window: NSWindow) -> Bool {
|
||||
if mainWindowContexts[ObjectIdentifier(window)] != nil {
|
||||
return true
|
||||
}
|
||||
guard let raw = window.identifier?.rawValue else { return false }
|
||||
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
||||
}
|
||||
|
|
@ -4338,7 +4731,8 @@ private extension NSWindow {
|
|||
// Command shortcuts when the terminal is focused — the local event monitor
|
||||
// (handleCustomShortcut) already handles app-level shortcuts, and anything
|
||||
// remaining should be menu items.
|
||||
if let ghosttyView = self.firstResponder as? GhosttyNSView {
|
||||
let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder)
|
||||
if let ghosttyView = firstResponderGhosttyView {
|
||||
// If the IME is composing, don't intercept key events — let them flow
|
||||
// through normal AppKit event dispatch so the input method can process them.
|
||||
if ghosttyView.hasMarkedText() {
|
||||
|
|
@ -4381,7 +4775,7 @@ private extension NSWindow {
|
|||
// When the terminal is focused, skip the full NSWindow.performKeyEquivalent
|
||||
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
|
||||
// events directly to the main menu. This avoids the broken SwiftUI focus path.
|
||||
if self.firstResponder is GhosttyNSView,
|
||||
if firstResponderGhosttyView != nil,
|
||||
event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
|
||||
let mainMenu = NSApp.mainMenu {
|
||||
let consumedByMenu = mainMenu.performKeyEquivalent(with: event)
|
||||
|
|
|
|||
|
|
@ -326,6 +326,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
private weak var installedContainerView: NSView?
|
||||
private weak var installedReferenceView: NSView?
|
||||
private var hasDeferredFullSyncScheduled = false
|
||||
private var hasExternalGeometrySyncScheduled = false
|
||||
private var geometryObservers: [NSObjectProtocol] = []
|
||||
|
||||
private struct Entry {
|
||||
weak var webView: WKWebView?
|
||||
|
|
@ -345,9 +347,73 @@ final class WindowBrowserPortal: NSObject {
|
|||
hostView.layer?.masksToBounds = true
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = true
|
||||
hostView.autoresizingMask = []
|
||||
installGeometryObservers(for: window)
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
let center = NotificationCenter.default
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didEndLiveResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSSplitView.didResizeSubviewsNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func removeGeometryObservers() {
|
||||
for observer in geometryObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
geometryObservers.removeAll()
|
||||
}
|
||||
|
||||
private func scheduleExternalGeometrySynchronize() {
|
||||
guard !hasExternalGeometrySyncScheduled else { return }
|
||||
hasExternalGeometrySyncScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hasExternalGeometrySyncScheduled = false
|
||||
self.synchronizeAllEntriesFromExternalGeometryChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeAllEntriesFromExternalGeometryChange() {
|
||||
guard ensureInstalled() else { return }
|
||||
installedContainerView?.layoutSubtreeIfNeeded()
|
||||
installedReferenceView?.layoutSubtreeIfNeeded()
|
||||
hostView.superview?.layoutSubtreeIfNeeded()
|
||||
hostView.layoutSubtreeIfNeeded()
|
||||
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func ensureInstalled() -> Bool {
|
||||
guard let window else { return false }
|
||||
|
|
@ -419,13 +485,32 @@ final class WindowBrowserPortal: NSObject {
|
|||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
|
||||
guard rect.origin.x.isFinite,
|
||||
rect.origin.y.isFinite,
|
||||
rect.size.width.isFinite,
|
||||
rect.size.height.isFinite else {
|
||||
return rect
|
||||
}
|
||||
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
|
||||
func snap(_ value: CGFloat) -> CGFloat {
|
||||
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
|
||||
}
|
||||
return NSRect(
|
||||
x: snap(rect.origin.x),
|
||||
y: snap(rect.origin.y),
|
||||
width: max(0, snap(rect.size.width)),
|
||||
height: max(0, snap(rect.size.height))
|
||||
)
|
||||
}
|
||||
|
||||
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
frame.minX < bounds.minX - epsilon ||
|
||||
frame.minY < bounds.minY - epsilon ||
|
||||
|
|
@ -765,7 +850,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
|
|
@ -838,6 +924,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
CATransaction.setDisableActions(true)
|
||||
containerView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
|
||||
|
|
@ -952,6 +1040,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
|
||||
func tearDown() {
|
||||
removeGeometryObservers()
|
||||
for webViewId in Array(entriesByWebViewId.keys) {
|
||||
detachWebView(withId: webViewId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -977,14 +977,6 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind
|
|||
return controller
|
||||
}
|
||||
|
||||
private struct CommandPaletteRowFramePreferenceKey: PreferenceKey {
|
||||
static var defaultValue: [Int: CGRect] = [:]
|
||||
|
||||
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs })
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspaceMountPolicy {
|
||||
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
||||
static let maxMountedWorkspaces = 1
|
||||
|
|
@ -1120,8 +1112,8 @@ struct ContentView: View {
|
|||
@State private var commandPaletteRenameDraft: String = ""
|
||||
@State private var commandPaletteSelectedResultIndex: Int = 0
|
||||
@State private var commandPaletteHoveredResultIndex: Int?
|
||||
@State private var commandPaletteLastSelectionIndex: Int = 0
|
||||
@State private var commandPaletteRowFrames: [Int: CGRect] = [:]
|
||||
@State private var commandPaletteScrollTargetIndex: Int?
|
||||
@State private var commandPaletteScrollTargetAnchor: UnitPoint?
|
||||
@State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget?
|
||||
@State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:]
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
|
|
@ -1197,11 +1189,6 @@ struct ContentView: View {
|
|||
case kind
|
||||
}
|
||||
|
||||
enum CommandPaletteScrollAnchor: Equatable {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
private struct CommandPaletteTrailingLabel {
|
||||
let text: String
|
||||
let style: CommandPaletteTrailingLabelStyle
|
||||
|
|
@ -1277,6 +1264,10 @@ struct ContentView: View {
|
|||
static let panelHasUnread = "panel.hasUnread"
|
||||
|
||||
static let updateHasAvailable = "update.hasAvailable"
|
||||
|
||||
static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String {
|
||||
"terminal.openTarget.\(target.rawValue).available"
|
||||
}
|
||||
}
|
||||
|
||||
private struct CommandPaletteCommandContribution {
|
||||
|
|
@ -1343,6 +1334,8 @@ struct ContentView: View {
|
|||
)
|
||||
private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1"
|
||||
private static let commandPaletteCommandsPrefix = ">"
|
||||
private static let minimumSidebarWidth: CGFloat = 186
|
||||
private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0
|
||||
|
||||
private enum SidebarResizerHandle: Hashable {
|
||||
case divider
|
||||
|
|
@ -1352,8 +1345,31 @@ struct ContentView: View {
|
|||
SidebarResizeInteraction.hitWidthPerSide
|
||||
}
|
||||
|
||||
private var maxSidebarWidth: CGFloat {
|
||||
(NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3
|
||||
private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat {
|
||||
let resolvedAvailableWidth = availableWidth
|
||||
?? observedWindow?.contentView?.bounds.width
|
||||
?? observedWindow?.contentLayoutRect.width
|
||||
?? NSApp.keyWindow?.contentView?.bounds.width
|
||||
?? NSApp.keyWindow?.contentLayoutRect.width
|
||||
if let resolvedAvailableWidth, resolvedAvailableWidth > 0 {
|
||||
return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio)
|
||||
}
|
||||
|
||||
let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width
|
||||
?? NSScreen.main?.frame.width
|
||||
?? 1920
|
||||
return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio)
|
||||
}
|
||||
|
||||
private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) {
|
||||
let nextWidth = max(
|
||||
Self.minimumSidebarWidth,
|
||||
min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth)
|
||||
)
|
||||
guard abs(nextWidth - sidebarWidth) > 0.5 else { return }
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
sidebarWidth = nextWidth
|
||||
}
|
||||
}
|
||||
|
||||
private func activateSidebarResizerCursor() {
|
||||
|
|
@ -1498,6 +1514,7 @@ struct ContentView: View {
|
|||
private func sidebarResizerHandleOverlay(
|
||||
_ handle: SidebarResizerHandle,
|
||||
width: CGFloat,
|
||||
availableWidth: CGFloat,
|
||||
accessibilityIdentifier: String? = nil
|
||||
) -> some View {
|
||||
Color.clear
|
||||
|
|
@ -1543,7 +1560,10 @@ struct ContentView: View {
|
|||
|
||||
activateSidebarResizerCursor()
|
||||
let startWidth = sidebarDragStartWidth ?? sidebarWidth
|
||||
let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width))
|
||||
let nextWidth = max(
|
||||
Self.minimumSidebarWidth,
|
||||
min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width)
|
||||
)
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
sidebarWidth = nextWidth
|
||||
}
|
||||
|
|
@ -1574,6 +1594,7 @@ struct ContentView: View {
|
|||
sidebarResizerHandleOverlay(
|
||||
.divider,
|
||||
width: sidebarResizerHitWidthPerSide * 2,
|
||||
availableWidth: totalWidth,
|
||||
accessibilityIdentifier: "SidebarResizer"
|
||||
)
|
||||
|
||||
|
|
@ -1582,6 +1603,12 @@ struct ContentView: View {
|
|||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: totalWidth, height: proxy.size.height, alignment: .leading)
|
||||
.onAppear {
|
||||
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
|
||||
}
|
||||
.onChange(of: totalWidth) {
|
||||
clampSidebarWidthIfNeeded(availableWidth: totalWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1610,7 +1637,11 @@ struct ContentView: View {
|
|||
ForEach(mountedWorkspaces) { tab in
|
||||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||||
// Keep the retiring workspace visible during handoff, but never input-active.
|
||||
// Allowing both selected+retiring workspaces to be input-active lets the
|
||||
// old workspace steal first responder (notably with WKWebView), which can
|
||||
// delay handoff completion and make browser returns feel laggy.
|
||||
let isInputActive = isSelectedWorkspace
|
||||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||||
WorkspaceContentView(
|
||||
|
|
@ -1952,6 +1983,25 @@ struct ContentView: View {
|
|||
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in
|
||||
guard let webView = notification.object as? WKWebView,
|
||||
let selectedTabId = tabManager.selectedTabId,
|
||||
let selectedWorkspace = tabManager.selectedWorkspace,
|
||||
let focusedPanelId = selectedWorkspace.focusedPanelId,
|
||||
let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId),
|
||||
focusedBrowser.webView === webView else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder")
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in
|
||||
guard let panelId = notification.object as? UUID,
|
||||
let selectedTabId = tabManager.selectedTabId,
|
||||
let selectedWorkspace = tabManager.selectedWorkspace,
|
||||
selectedWorkspace.focusedPanelId == panelId,
|
||||
selectedWorkspace.browserPanel(for: panelId) != nil else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar")
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(tabManager.$tabs) { tabs in
|
||||
let existingIds = Set(tabs.map { $0.id })
|
||||
if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) {
|
||||
|
|
@ -2102,6 +2152,13 @@ struct ContentView: View {
|
|||
AppDelegate.shared?.fullscreenControlsViewModel = nil
|
||||
})
|
||||
|
||||
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in
|
||||
guard let window = notification.object as? NSWindow,
|
||||
window === observedWindow else { return }
|
||||
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
|
||||
updateSidebarResizerBandState()
|
||||
})
|
||||
|
||||
view = AnyView(view.onChange(of: sidebarWidth) { _ in
|
||||
updateSidebarResizerBandState()
|
||||
})
|
||||
|
|
@ -2129,6 +2186,7 @@ struct ContentView: View {
|
|||
DispatchQueue.main.async {
|
||||
observedWindow = window
|
||||
isFullScreen = window.styleMask.contains(.fullScreen)
|
||||
clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width)
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
installSidebarResizerPointerMonitorIfNeeded()
|
||||
updateSidebarResizerBandState()
|
||||
|
|
@ -2377,7 +2435,7 @@ struct ContentView: View {
|
|||
private var commandPaletteCommandListView: some View {
|
||||
let visibleResults = Array(commandPaletteResults)
|
||||
let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
let commandPaletteListMaxHeight: CGFloat = 216
|
||||
let commandPaletteListMaxHeight: CGFloat = 450
|
||||
let commandPaletteRowHeight: CGFloat = 24
|
||||
let commandPaletteEmptyStateHeight: CGFloat = 44
|
||||
let commandPaletteListContentHeight = visibleResults.isEmpty
|
||||
|
|
@ -2421,133 +2479,85 @@ struct ContentView: View {
|
|||
|
||||
Divider()
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if visibleResults.isEmpty {
|
||||
Text(commandPaletteEmptyStateText)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
|
||||
let isSelected = index == selectedIndex
|
||||
let isHovered = commandPaletteHoveredResultIndex == index
|
||||
let rowBackground: Color = isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: (isHovered ? Color.primary.opacity(0.08) : .clear)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if visibleResults.isEmpty {
|
||||
Text(commandPaletteEmptyStateText)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in
|
||||
let isSelected = index == selectedIndex
|
||||
let isHovered = commandPaletteHoveredResultIndex == index
|
||||
let rowBackground: Color = isSelected
|
||||
? Color.accentColor.opacity(0.12)
|
||||
: (isHovered ? Color.primary.opacity(0.08) : .clear)
|
||||
|
||||
Button {
|
||||
runCommandPaletteCommand(result.command)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
commandPaletteHighlightedTitleText(
|
||||
result.command.title,
|
||||
matchedIndices: result.titleMatchIndices
|
||||
)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
|
||||
if let trailingLabel = commandPaletteTrailingLabel(for: result.command) {
|
||||
switch trailingLabel.style {
|
||||
case .shortcut:
|
||||
Text(trailingLabel.text)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
case .kind:
|
||||
Text(trailingLabel.text)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(rowBackground)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: CommandPaletteRowFramePreferenceKey.self,
|
||||
value: [index: geometry.frame(in: .named("commandPaletteListScroll"))]
|
||||
)
|
||||
}
|
||||
Button {
|
||||
runCommandPaletteCommand(result.command)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
commandPaletteHighlightedTitleText(
|
||||
result.command.title,
|
||||
matchedIndices: result.titleMatchIndices
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(index)
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
commandPaletteHoveredResultIndex = index
|
||||
} else if commandPaletteHoveredResultIndex == index {
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
|
||||
if let trailingLabel = commandPaletteTrailingLabel(for: result.command) {
|
||||
switch trailingLabel.style {
|
||||
case .shortcut:
|
||||
Text(trailingLabel.text)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
.background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
case .kind:
|
||||
Text(trailingLabel.text)
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(rowBackground)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(index)
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
commandPaletteHoveredResultIndex = index
|
||||
} else if commandPaletteHoveredResultIndex == index {
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Force a fresh row tree per query so rendered labels/actions stay in lockstep.
|
||||
.id(commandPaletteQuery)
|
||||
}
|
||||
.coordinateSpace(name: "commandPaletteListScroll")
|
||||
.frame(height: commandPaletteListHeight)
|
||||
.onChange(of: commandPaletteSelectedResultIndex) { _ in
|
||||
guard !visibleResults.isEmpty else { return }
|
||||
let index = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
let previousIndex = commandPaletteLastSelectionIndex
|
||||
defer { commandPaletteLastSelectionIndex = index }
|
||||
|
||||
guard let anchorDecision = Self.commandPaletteScrollAnchor(
|
||||
selectedIndex: index,
|
||||
previousIndex: previousIndex,
|
||||
resultCount: visibleResults.count,
|
||||
selectedFrame: commandPaletteRowFrames[index],
|
||||
viewportHeight: commandPaletteListHeight,
|
||||
contentHeight: commandPaletteListContentHeight
|
||||
) else { return }
|
||||
|
||||
let anchor: UnitPoint
|
||||
switch anchorDecision {
|
||||
case .top:
|
||||
anchor = .top
|
||||
case .bottom:
|
||||
anchor = .bottom
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeOut(duration: 0.1)) {
|
||||
proxy.scrollTo(index, anchor: anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: visibleResults.count) { _ in
|
||||
commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
}
|
||||
.onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in
|
||||
commandPaletteRowFrames = frames
|
||||
guard !visibleResults.isEmpty else { return }
|
||||
let index = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: index,
|
||||
resultCount: visibleResults.count,
|
||||
selectedFrame: frames[index],
|
||||
viewportHeight: commandPaletteListHeight,
|
||||
contentHeight: commandPaletteListContentHeight
|
||||
) else { return }
|
||||
let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
proxy.scrollTo(index, anchor: anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
// Force a fresh row tree per query so rendered labels/actions stay in lockstep.
|
||||
.id(commandPaletteQuery)
|
||||
}
|
||||
.frame(height: commandPaletteListHeight)
|
||||
.scrollPosition(
|
||||
id: Binding(
|
||||
get: { commandPaletteScrollTargetIndex },
|
||||
// Ignore passive readback so manual scrolling doesn't mutate selection-follow state.
|
||||
set: { _ in }
|
||||
),
|
||||
anchor: commandPaletteScrollTargetAnchor
|
||||
)
|
||||
.onChange(of: commandPaletteSelectedResultIndex) { _ in
|
||||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true)
|
||||
}
|
||||
|
||||
// Keep Esc-to-close behavior without showing footer controls.
|
||||
|
|
@ -2562,20 +2572,19 @@ struct ContentView: View {
|
|||
}
|
||||
.onAppear {
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex
|
||||
commandPaletteRowFrames = [:]
|
||||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
|
||||
resetCommandPaletteSearchFocus()
|
||||
}
|
||||
.onChange(of: commandPaletteQuery) { _ in
|
||||
commandPaletteSelectedResultIndex = 0
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteLastSelectionIndex = 0
|
||||
commandPaletteRowFrames = [:]
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
.onChange(of: visibleResults.count) { _ in
|
||||
commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count)
|
||||
commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex
|
||||
updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false)
|
||||
if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count {
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
}
|
||||
|
|
@ -3178,18 +3187,29 @@ struct ContentView: View {
|
|||
if let panelContext = focusedPanelContext {
|
||||
let workspace = panelContext.workspace
|
||||
let panelId = panelContext.panelId
|
||||
let panelIsTerminal = panelContext.panel.panelType == .terminal
|
||||
snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true)
|
||||
snapshot.setString(
|
||||
CommandPaletteContextKeys.panelName,
|
||||
panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle)
|
||||
)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId))
|
||||
let hasUnread = workspace.manualUnreadPanelIds.contains(panelId)
|
||||
|| notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId)
|
||||
snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread)
|
||||
|
||||
if panelIsTerminal {
|
||||
let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets
|
||||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||||
snapshot.setBool(
|
||||
CommandPaletteContextKeys.terminalOpenTargetAvailable(target),
|
||||
availableTargets.contains(target)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if case .updateAvailable = updateViewModel.effectiveState {
|
||||
|
|
@ -3600,15 +3620,20 @@ struct ContentView: View {
|
|||
)
|
||||
)
|
||||
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.terminalOpenDirectory",
|
||||
title: constant("Open Current Directory in IDE"),
|
||||
subtitle: terminalPanelSubtitle,
|
||||
keywords: ["terminal", "directory", "open", "ide", "code", "default app"],
|
||||
when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) }
|
||||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: target.commandPaletteCommandId,
|
||||
title: constant(target.commandPaletteTitle),
|
||||
subtitle: terminalPanelSubtitle,
|
||||
keywords: target.commandPaletteKeywords,
|
||||
when: { context in
|
||||
context.bool(CommandPaletteContextKeys.panelIsTerminal)
|
||||
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target))
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
contributions.append(
|
||||
CommandPaletteCommandContribution(
|
||||
commandId: "palette.terminalFind",
|
||||
|
|
@ -3871,9 +3896,11 @@ struct ContentView: View {
|
|||
_ = tabManager.createBrowserSplit(direction: .right, url: url)
|
||||
}
|
||||
|
||||
registry.register(commandId: "palette.terminalOpenDirectory") {
|
||||
if !openFocusedDirectoryInDefaultApp() {
|
||||
NSSound.beep()
|
||||
for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets {
|
||||
registry.register(commandId: target.commandPaletteCommandId) {
|
||||
if !openFocusedDirectory(in: target) {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
}
|
||||
registry.register(commandId: "palette.terminalFind") {
|
||||
|
|
@ -3937,61 +3964,43 @@ struct ContentView: View {
|
|||
return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1)
|
||||
}
|
||||
|
||||
static func commandPaletteScrollAnchor(
|
||||
static func commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: Int,
|
||||
previousIndex: Int,
|
||||
resultCount: Int,
|
||||
selectedFrame: CGRect?,
|
||||
viewportHeight: CGFloat,
|
||||
contentHeight: CGFloat,
|
||||
epsilon: CGFloat = 0.5
|
||||
) -> CommandPaletteScrollAnchor? {
|
||||
resultCount: Int
|
||||
) -> UnitPoint? {
|
||||
guard resultCount > 0 else { return nil }
|
||||
guard contentHeight > viewportHeight else { return nil }
|
||||
|
||||
// Always pin edges exactly into view when selection reaches first/last.
|
||||
if selectedIndex <= 0 {
|
||||
return .top
|
||||
return UnitPoint.top
|
||||
}
|
||||
if selectedIndex >= resultCount - 1 {
|
||||
return .bottom
|
||||
return UnitPoint.bottom
|
||||
}
|
||||
|
||||
if let frame = selectedFrame,
|
||||
frame.minY >= (0 - epsilon),
|
||||
frame.maxY <= (viewportHeight + epsilon) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return selectedIndex >= previousIndex ? .bottom : .top
|
||||
return nil
|
||||
}
|
||||
|
||||
static func commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: Int,
|
||||
resultCount: Int,
|
||||
selectedFrame: CGRect?,
|
||||
viewportHeight: CGFloat,
|
||||
contentHeight: CGFloat,
|
||||
epsilon: CGFloat = 0.5
|
||||
) -> CommandPaletteScrollAnchor? {
|
||||
guard resultCount > 0 else { return nil }
|
||||
guard contentHeight > viewportHeight else { return nil }
|
||||
|
||||
let isTop = selectedIndex <= 0
|
||||
let isBottom = selectedIndex >= (resultCount - 1)
|
||||
guard isTop || isBottom else { return nil }
|
||||
|
||||
guard let frame = selectedFrame else {
|
||||
return isTop ? .top : .bottom
|
||||
private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) {
|
||||
guard resultCount > 0 else {
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
return
|
||||
}
|
||||
|
||||
if isTop {
|
||||
let topDelta = abs(frame.minY)
|
||||
return topDelta > epsilon ? .top : nil
|
||||
}
|
||||
let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount)
|
||||
commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: selectedIndex,
|
||||
resultCount: resultCount
|
||||
)
|
||||
|
||||
let bottomDelta = abs(frame.maxY - viewportHeight)
|
||||
return bottomDelta > epsilon ? .bottom : nil
|
||||
let assignTarget = {
|
||||
commandPaletteScrollTargetIndex = selectedIndex
|
||||
}
|
||||
if animated {
|
||||
withAnimation(.easeOut(duration: 0.1)) {
|
||||
assignTarget()
|
||||
}
|
||||
} else {
|
||||
assignTarget()
|
||||
}
|
||||
}
|
||||
|
||||
private func moveCommandPaletteSelection(by delta: Int) {
|
||||
|
|
@ -4185,8 +4194,8 @@ struct ContentView: View {
|
|||
commandPaletteRenameDraft = ""
|
||||
commandPaletteSelectedResultIndex = 0
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteLastSelectionIndex = 0
|
||||
commandPaletteRowFrames = [:]
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
resetCommandPaletteSearchFocus()
|
||||
syncCommandPaletteDebugStateForObservedWindow()
|
||||
}
|
||||
|
|
@ -4199,8 +4208,8 @@ struct ContentView: View {
|
|||
commandPaletteRenameDraft = ""
|
||||
commandPaletteSelectedResultIndex = 0
|
||||
commandPaletteHoveredResultIndex = nil
|
||||
commandPaletteLastSelectionIndex = 0
|
||||
commandPaletteRowFrames = [:]
|
||||
commandPaletteScrollTargetIndex = nil
|
||||
commandPaletteScrollTargetAnchor = nil
|
||||
isCommandPaletteSearchFocused = false
|
||||
isCommandPaletteRenameFocused = false
|
||||
commandPaletteRestoreFocusTarget = nil
|
||||
|
|
@ -4427,9 +4436,22 @@ struct ContentView: View {
|
|||
return NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func openFocusedDirectoryInDefaultApp() -> Bool {
|
||||
private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool {
|
||||
guard let directoryURL = focusedTerminalDirectoryURL() else { return false }
|
||||
return NSWorkspace.shared.open(directoryURL)
|
||||
return openFocusedDirectory(directoryURL, in: target)
|
||||
}
|
||||
|
||||
private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool {
|
||||
switch target {
|
||||
case .finder:
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
|
||||
return true
|
||||
default:
|
||||
guard let applicationURL = target.applicationURL() else { return false }
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func focusedTerminalDirectoryURL() -> URL? {
|
||||
|
|
|
|||
|
|
@ -1501,6 +1501,17 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
/// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived
|
||||
/// from backing-space points and truncated (never rounded up).
|
||||
private func pixelDimension(from value: CGFloat) -> UInt32 {
|
||||
guard value.isFinite else { return 0 }
|
||||
let floored = floor(max(0, value))
|
||||
if floored >= CGFloat(UInt32.max) {
|
||||
return UInt32.max
|
||||
}
|
||||
return UInt32(floored)
|
||||
}
|
||||
|
||||
private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) {
|
||||
let scale = max(
|
||||
1.0,
|
||||
|
|
@ -1616,6 +1627,13 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
surfaceCallbackContext = callbackContext
|
||||
surfaceConfig.scale_factor = scaleFactors.layer
|
||||
surfaceConfig.context = surfaceContext
|
||||
#if DEBUG
|
||||
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
|
||||
dlog(
|
||||
"zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
|
||||
"templateFont=\(templateFontText)"
|
||||
)
|
||||
#endif
|
||||
var envVars: [ghostty_env_var_s] = []
|
||||
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
|
||||
defer {
|
||||
|
|
@ -1761,6 +1779,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
#endif
|
||||
return
|
||||
}
|
||||
guard let createdSurface = surface else { return }
|
||||
|
||||
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
|
||||
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
|
||||
|
|
@ -1772,29 +1791,66 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
if let screen = view.window?.screen ?? NSScreen.main,
|
||||
let displayID = screen.displayID,
|
||||
displayID != 0 {
|
||||
ghostty_surface_set_display_id(surface, displayID)
|
||||
ghostty_surface_set_display_id(createdSurface, displayID)
|
||||
}
|
||||
|
||||
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
|
||||
let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero))
|
||||
let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero))
|
||||
ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
|
||||
let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size
|
||||
let wpx = pixelDimension(from: backingSize.width)
|
||||
let hpx = pixelDimension(from: backingSize.height)
|
||||
if wpx > 0, hpx > 0 {
|
||||
ghostty_surface_set_size(surface, wpx, hpx)
|
||||
ghostty_surface_set_size(createdSurface, wpx, hpx)
|
||||
lastPixelWidth = wpx
|
||||
lastPixelHeight = hpx
|
||||
lastXScale = scaleFactors.x
|
||||
lastYScale = scaleFactors.y
|
||||
}
|
||||
|
||||
// Some GhosttyKit builds can drop inherited font_size during post-create
|
||||
// config/scale reconciliation. If runtime points don't match the inherited
|
||||
// template points, re-apply via binding action so all creation paths
|
||||
// (new surface, split, new workspace) preserve zoom from the source terminal.
|
||||
if let inheritedFontPoints = configTemplate?.font_size,
|
||||
inheritedFontPoints > 0 {
|
||||
let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface)
|
||||
let shouldReapply = {
|
||||
guard let currentFontPoints else { return true }
|
||||
return abs(currentFontPoints - inheritedFontPoints) > 0.05
|
||||
}()
|
||||
if shouldReapply {
|
||||
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
|
||||
_ = performBindingAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
flushPendingTextIfNeeded()
|
||||
|
||||
#if DEBUG
|
||||
let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map {
|
||||
String(format: "%.2f", $0)
|
||||
} ?? "nil"
|
||||
dlog(
|
||||
"zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
|
||||
"runtimeFont=\(runtimeFontText)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
|
||||
func updateSize(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
xScale: CGFloat,
|
||||
yScale: CGFloat,
|
||||
layerScale: CGFloat,
|
||||
backingSize: CGSize? = nil
|
||||
) {
|
||||
guard let surface = surface else { return }
|
||||
_ = layerScale
|
||||
|
||||
let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero))
|
||||
let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero))
|
||||
let resolvedBackingWidth = backingSize?.width ?? (width * xScale)
|
||||
let resolvedBackingHeight = backingSize?.height ?? (height * yScale)
|
||||
let wpx = pixelDimension(from: resolvedBackingWidth)
|
||||
let hpx = pixelDimension(from: resolvedBackingHeight)
|
||||
guard wpx > 0, hpx > 0 else { return }
|
||||
|
||||
let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale)
|
||||
|
|
@ -2079,6 +2135,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private func setup() {
|
||||
// Only enable our instrumented CAMetalLayer in targeted debug/test scenarios.
|
||||
// The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs.
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = true
|
||||
installEventMonitor()
|
||||
updateTrackingAreas()
|
||||
registerForDraggedTypes(Array(Self.dropTypes))
|
||||
|
|
@ -2206,17 +2264,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
ghostty_surface_set_display_id(surface, displayID)
|
||||
}
|
||||
|
||||
// Recompute from current bounds after layout, not stale pending sizes.
|
||||
// Recompute from current bounds after layout. Pending size is only a fallback
|
||||
// when we don't have usable bounds (e.g. detached/off-window transitions).
|
||||
superview?.layoutSubtreeIfNeeded()
|
||||
layoutSubtreeIfNeeded()
|
||||
let targetSize: CGSize = {
|
||||
let current = bounds.size
|
||||
if current.width > 0, current.height > 0 {
|
||||
return current
|
||||
}
|
||||
return pendingSurfaceSize ?? current
|
||||
}()
|
||||
updateSurfaceSize(size: targetSize)
|
||||
updateSurfaceSize()
|
||||
applySurfaceBackground()
|
||||
applySurfaceColorScheme(force: true)
|
||||
applyWindowBackgroundIfActive()
|
||||
|
|
@ -2256,9 +2308,30 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize {
|
||||
if let size,
|
||||
size.width > 0,
|
||||
size.height > 0 {
|
||||
return size
|
||||
}
|
||||
|
||||
let currentBounds = bounds.size
|
||||
if currentBounds.width > 0, currentBounds.height > 0 {
|
||||
return currentBounds
|
||||
}
|
||||
|
||||
if let pending = pendingSurfaceSize,
|
||||
pending.width > 0,
|
||||
pending.height > 0 {
|
||||
return pending
|
||||
}
|
||||
|
||||
return currentBounds
|
||||
}
|
||||
|
||||
private func updateSurfaceSize(size: CGSize? = nil) {
|
||||
guard let terminalSurface = terminalSurface else { return }
|
||||
let size = size ?? bounds.size
|
||||
let size = resolvedSurfaceSize(preferred: size)
|
||||
guard size.width > 0 && size.height > 0 else {
|
||||
#if DEBUG
|
||||
let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))"
|
||||
|
|
@ -2318,12 +2391,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
let xScale = backingSize.width / size.width
|
||||
let yScale = backingSize.height / size.height
|
||||
let layerScale = max(1.0, window.backingScaleFactor)
|
||||
let drawablePixelSize = CGSize(
|
||||
width: floor(max(0, backingSize.width)),
|
||||
height: floor(max(0, backingSize.height))
|
||||
)
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
layer?.contentsScale = layerScale
|
||||
layer?.masksToBounds = true
|
||||
if let metalLayer = layer as? CAMetalLayer {
|
||||
metalLayer.drawableSize = backingSize
|
||||
metalLayer.drawableSize = drawablePixelSize
|
||||
}
|
||||
CATransaction.commit()
|
||||
|
||||
|
|
@ -2332,9 +2410,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
height: size.height,
|
||||
xScale: xScale,
|
||||
yScale: yScale,
|
||||
layerScale: layerScale
|
||||
layerScale: layerScale,
|
||||
backingSize: backingSize
|
||||
)
|
||||
pendingSurfaceSize = nil
|
||||
}
|
||||
|
||||
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
|
||||
|
|
@ -3524,6 +3602,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
documentView.addSubview(surfaceView)
|
||||
|
||||
super.init(frame: .zero)
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = true
|
||||
|
||||
backgroundView.wantsLayer = true
|
||||
backgroundView.layer?.backgroundColor =
|
||||
|
|
@ -3661,6 +3741,12 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
synchronizeGeometryAndContent()
|
||||
}
|
||||
|
||||
/// Request an immediate terminal redraw after geometry updates so stale IOSurface
|
||||
/// contents do not remain stretched during live resize churn.
|
||||
func refreshSurfaceNow() {
|
||||
surfaceView.terminalSurface?.forceRefresh()
|
||||
}
|
||||
|
||||
private func synchronizeGeometryAndContent() {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
|
@ -3670,7 +3756,6 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
scrollView.frame = bounds
|
||||
let targetSize = scrollView.bounds.size
|
||||
surfaceView.frame.size = targetSize
|
||||
surfaceView.pushTargetSurfaceSize(targetSize)
|
||||
documentView.frame.size.width = scrollView.bounds.width
|
||||
inactiveOverlayView.frame = bounds
|
||||
if let zone = activeDropZone {
|
||||
|
|
@ -3694,6 +3779,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
updateFlashPath()
|
||||
synchronizeScrollView()
|
||||
synchronizeSurfaceView()
|
||||
synchronizeCoreSurface()
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
|
|
@ -3713,8 +3799,13 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
// No-op: focus is driven by first-responder changes.
|
||||
_ = self
|
||||
guard let self, let window = self.window else { return }
|
||||
// Losing key window does not always trigger first-responder resignation, so force
|
||||
// the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync.
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
})
|
||||
if window.isKeyWindow { applyFirstResponderIfNeeded() }
|
||||
}
|
||||
|
|
@ -4566,6 +4657,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
surfaceView.frame.origin = visibleRect.origin
|
||||
}
|
||||
|
||||
/// Match upstream Ghostty behavior: use content area width (excluding non-content
|
||||
/// regions such as scrollbar space) when telling libghostty the terminal size.
|
||||
private func synchronizeCoreSurface() {
|
||||
let width = scrollView.contentSize.width
|
||||
let height = surfaceView.frame.height
|
||||
guard width > 0, height > 0 else { return }
|
||||
surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
|
||||
}
|
||||
|
||||
private func updateNotificationRingPath() {
|
||||
updateOverlayRingPath(
|
||||
layer: notificationRingLayer,
|
||||
|
|
|
|||
|
|
@ -380,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
|
|||
return preparedRequest
|
||||
}
|
||||
|
||||
private let browserEmbeddedNavigationSchemes: Set<String> = [
|
||||
"about",
|
||||
"applewebdata",
|
||||
"blob",
|
||||
"data",
|
||||
"http",
|
||||
"https",
|
||||
"javascript",
|
||||
]
|
||||
|
||||
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
|
||||
return !browserEmbeddedNavigationSchemes.contains(scheme)
|
||||
}
|
||||
|
||||
enum BrowserUserAgentSettings {
|
||||
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
|
||||
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
|
||||
|
|
@ -2638,6 +2653,22 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
|
||||
// Hand these off to macOS so the owning app can handle them.
|
||||
if let url = navigationAction.request.url,
|
||||
navigationAction.targetFrame?.isMainFrame != false,
|
||||
browserShouldOpenURLExternally(url) {
|
||||
let opened = NSWorkspace.shared.open(url)
|
||||
if !opened {
|
||||
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
||||
#endif
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// target=_blank or window.open() — navigate in the current webview
|
||||
if navigationAction.targetFrame == nil,
|
||||
navigationAction.request.url != nil {
|
||||
|
|
@ -2761,6 +2792,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
|
|||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
if let url = navigationAction.request.url {
|
||||
if browserShouldOpenURLExternally(url) {
|
||||
let opened = NSWorkspace.shared.open(url)
|
||||
if !opened {
|
||||
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
if let requestNavigation {
|
||||
let intent: BrowserInsecureHTTPNavigationIntent =
|
||||
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
|
||||
|
|
|
|||
|
|
@ -3070,6 +3070,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator: Coordinator,
|
||||
generation: Int
|
||||
) {
|
||||
let retryInterval: TimeInterval = 1.0 / 60.0
|
||||
// Don't schedule multiple overlapping retries.
|
||||
guard coordinator.attachRetryWorkItem == nil else { return }
|
||||
|
||||
|
|
@ -3102,7 +3103,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// Be generous here: bonsplit structural updates can keep a representable
|
||||
// container off-window longer than a few seconds under load.
|
||||
if coordinator.attachRetryCount < 400 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
|
||||
scheduleAttachRetry(
|
||||
webView,
|
||||
panel: panel,
|
||||
|
|
@ -3139,7 +3140,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
|
||||
coordinator.attachRetryWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ final class CmuxWebView: WKWebView {
|
|||
return false
|
||||
}
|
||||
let result = super.becomeFirstResponder()
|
||||
if result {
|
||||
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
|
||||
}
|
||||
#if DEBUG
|
||||
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
|
||||
dlog(
|
||||
|
|
|
|||
|
|
@ -753,9 +753,15 @@ class TabManager: ObservableObject {
|
|||
@discardableResult
|
||||
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
|
||||
let newWorkspace = Workspace(
|
||||
title: "Terminal \(tabs.count + 1)",
|
||||
workingDirectory: workingDirectory,
|
||||
portOrdinal: ordinal,
|
||||
configTemplate: inheritedConfig
|
||||
)
|
||||
wireClosedBrowserTracking(for: newWorkspace)
|
||||
let insertIndex = newTabInsertIndex()
|
||||
if insertIndex >= 0 && insertIndex <= tabs.count {
|
||||
|
|
@ -785,6 +791,36 @@ class TabManager: ObservableObject {
|
|||
@discardableResult
|
||||
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
|
||||
|
||||
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
if let focusedTerminal = workspace.focusedTerminalPanel {
|
||||
return focusedTerminal
|
||||
}
|
||||
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
|
||||
return rememberedTerminal
|
||||
}
|
||||
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
|
||||
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
|
||||
return paneTerminal
|
||||
}
|
||||
return workspace.terminalPanelForConfigInheritance()
|
||||
}
|
||||
|
||||
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
|
||||
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
|
||||
return cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: sourceSurface,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB
|
||||
)
|
||||
}
|
||||
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
|
||||
var config = ghostty_surface_config_new()
|
||||
config.font_size = fallbackFontPoints
|
||||
return config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
|
||||
guard let directory else { return nil }
|
||||
let normalized = normalizeDirectory(directory)
|
||||
|
|
@ -3113,6 +3149,7 @@ extension Notification.Name {
|
|||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
|
||||
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
|
||||
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
||||
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
||||
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
||||
|
|
|
|||
|
|
@ -546,6 +546,8 @@ final class WindowTerminalPortal: NSObject {
|
|||
private weak var installedReferenceView: NSView?
|
||||
private var installConstraints: [NSLayoutConstraint] = []
|
||||
private var hasDeferredFullSyncScheduled = false
|
||||
private var hasExternalGeometrySyncScheduled = false
|
||||
private var geometryObservers: [NSObjectProtocol] = []
|
||||
#if DEBUG
|
||||
private var lastLoggedBonsplitContainerSignature: String?
|
||||
#endif
|
||||
|
|
@ -563,13 +565,141 @@ final class WindowTerminalPortal: NSObject {
|
|||
init(window: NSWindow) {
|
||||
self.window = window
|
||||
super.init()
|
||||
hostView.wantsLayer = false
|
||||
hostView.wantsLayer = true
|
||||
hostView.layer?.masksToBounds = true
|
||||
hostView.postsFrameChangedNotifications = true
|
||||
hostView.postsBoundsChangedNotifications = true
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
|
||||
dividerOverlayView.autoresizingMask = [.width, .height]
|
||||
installGeometryObservers(for: window)
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
let center = NotificationCenter.default
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSWindow.didEndLiveResizeNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSSplitView.didResizeSubviewsNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSView.frameDidChangeNotification,
|
||||
object: hostView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
geometryObservers.append(center.addObserver(
|
||||
forName: NSView.boundsDidChangeNotification,
|
||||
object: hostView,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func removeGeometryObservers() {
|
||||
for observer in geometryObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
geometryObservers.removeAll()
|
||||
}
|
||||
|
||||
private func scheduleExternalGeometrySynchronize() {
|
||||
guard !hasExternalGeometrySyncScheduled else { return }
|
||||
hasExternalGeometrySyncScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.hasExternalGeometrySyncScheduled = false
|
||||
self.synchronizeAllEntriesFromExternalGeometryChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func synchronizeLayoutHierarchy() {
|
||||
installedContainerView?.layoutSubtreeIfNeeded()
|
||||
installedReferenceView?.layoutSubtreeIfNeeded()
|
||||
hostView.superview?.layoutSubtreeIfNeeded()
|
||||
hostView.layoutSubtreeIfNeeded()
|
||||
_ = synchronizeHostFrameToReference()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func synchronizeHostFrameToReference() -> Bool {
|
||||
guard let container = installedContainerView,
|
||||
let reference = installedReferenceView else {
|
||||
return false
|
||||
}
|
||||
let frameInContainer = container.convert(reference.bounds, from: reference)
|
||||
let hasFiniteFrame =
|
||||
frameInContainer.origin.x.isFinite &&
|
||||
frameInContainer.origin.y.isFinite &&
|
||||
frameInContainer.size.width.isFinite &&
|
||||
frameInContainer.size.height.isFinite
|
||||
guard hasFiniteFrame else { return false }
|
||||
|
||||
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostView.frame = frameInContainer
|
||||
CATransaction.commit()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
|
||||
"frame=\(portalDebugFrame(frameInContainer))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
return frameInContainer.width > 1 && frameInContainer.height > 1
|
||||
}
|
||||
|
||||
private func synchronizeAllEntriesFromExternalGeometryChange() {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
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.
|
||||
for entry in entriesByHostedId.values {
|
||||
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDividerOverlayOnTop() {
|
||||
if dividerOverlayView.superview !== hostView {
|
||||
dividerOverlayView.frame = hostView.bounds
|
||||
|
|
@ -618,6 +748,8 @@ final class WindowTerminalPortal: NSObject {
|
|||
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
|
||||
}
|
||||
|
||||
synchronizeLayoutHierarchy()
|
||||
_ = synchronizeHostFrameToReference()
|
||||
ensureDividerOverlayOnTop()
|
||||
|
||||
return true
|
||||
|
|
@ -647,13 +779,32 @@ final class WindowTerminalPortal: NSObject {
|
|||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
|
||||
guard rect.origin.x.isFinite,
|
||||
rect.origin.y.isFinite,
|
||||
rect.size.width.isFinite,
|
||||
rect.size.height.isFinite else {
|
||||
return rect
|
||||
}
|
||||
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
|
||||
func snap(_ value: CGFloat) -> CGFloat {
|
||||
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
|
||||
}
|
||||
return NSRect(
|
||||
x: snap(rect.origin.x),
|
||||
y: snap(rect.origin.y),
|
||||
width: max(0, snap(rect.size.width)),
|
||||
height: max(0, snap(rect.size.height))
|
||||
)
|
||||
}
|
||||
|
||||
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
||||
guard let viewIndex = container.subviews.firstIndex(of: view),
|
||||
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
||||
|
|
@ -691,6 +842,58 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
|
||||
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
|
||||
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
|
||||
/// visible rect that should drive portal geometry.
|
||||
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
|
||||
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
var current = anchorView.superview
|
||||
while let ancestor = current {
|
||||
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
|
||||
let finiteAncestorBounds =
|
||||
ancestorBoundsInWindow.origin.x.isFinite &&
|
||||
ancestorBoundsInWindow.origin.y.isFinite &&
|
||||
ancestorBoundsInWindow.size.width.isFinite &&
|
||||
ancestorBoundsInWindow.size.height.isFinite
|
||||
if finiteAncestorBounds {
|
||||
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
|
||||
if frameInWindow.isNull { return .zero }
|
||||
}
|
||||
if ancestor === installedReferenceView { break }
|
||||
current = ancestor.superview
|
||||
}
|
||||
return frameInWindow
|
||||
}
|
||||
|
||||
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
let hasFiniteFrame =
|
||||
frameInHost.origin.x.isFinite &&
|
||||
frameInHost.origin.y.isFinite &&
|
||||
frameInHost.size.width.isFinite &&
|
||||
frameInHost.size.height.isFinite
|
||||
guard hasFiniteFrame else { return nil }
|
||||
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
hostBounds.origin.y.isFinite &&
|
||||
hostBounds.size.width.isFinite &&
|
||||
hostBounds.size.height.isFinite
|
||||
if hasFiniteHostBounds {
|
||||
let clampedFrame = frameInHost.intersection(hostBounds)
|
||||
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
|
||||
return clampedFrame
|
||||
}
|
||||
}
|
||||
|
||||
return frameInHost
|
||||
}
|
||||
|
||||
func detachHostedView(withId hostedId: ObjectIdentifier) {
|
||||
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
|
||||
if let anchor = entry.anchorView {
|
||||
|
|
@ -782,6 +985,32 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
|
||||
// Seed frame/bounds before entering the window so a freshly reparented
|
||||
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
|
||||
if let seededFrame = seededFrameInHost(for: anchorView),
|
||||
seededFrame.width > 0,
|
||||
seededFrame.height > 0 {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = seededFrame
|
||||
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
|
||||
CATransaction.commit()
|
||||
} else {
|
||||
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
|
||||
// synchronizeHostedView resolves a valid target frame on the next layout tick.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = .zero
|
||||
hostedView.bounds = .zero
|
||||
CATransaction.commit()
|
||||
hostedView.isHidden = true
|
||||
}
|
||||
// Keep inner scroll/surface geometry in sync with the seeded outer frame
|
||||
// before the hosted view enters a window.
|
||||
hostedView.reconcileGeometryNow()
|
||||
|
||||
if hostedView.superview !== hostView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -807,10 +1036,13 @@ final class WindowTerminalPortal: NSObject {
|
|||
ensureDividerOverlayOnTop()
|
||||
|
||||
synchronizeHostedView(withId: hostedId)
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
pruneDeadEntries()
|
||||
}
|
||||
|
||||
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
pruneDeadEntries()
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let primaryHostedId = hostedByAnchorId[anchorId]
|
||||
|
|
@ -837,6 +1069,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
|
||||
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
|
||||
guard ensureInstalled() else { return }
|
||||
synchronizeLayoutHierarchy()
|
||||
pruneDeadEntries()
|
||||
let hostedIds = Array(entriesByHostedId.keys)
|
||||
for hostedId in hostedIds {
|
||||
|
|
@ -879,24 +1112,51 @@ final class WindowTerminalPortal: NSObject {
|
|||
return
|
||||
}
|
||||
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
_ = synchronizeHostFrameToReference()
|
||||
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
|
||||
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
|
||||
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
|
||||
#if DEBUG
|
||||
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
|
||||
#endif
|
||||
let hostBounds = hostView.bounds
|
||||
let hasFiniteHostBounds =
|
||||
hostBounds.origin.x.isFinite &&
|
||||
hostBounds.origin.y.isFinite &&
|
||||
hostBounds.size.width.isFinite &&
|
||||
hostBounds.size.height.isFinite
|
||||
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
|
||||
if !hostBoundsReady {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
|
||||
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
|
||||
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = true
|
||||
scheduleDeferredFullSynchronizeAll()
|
||||
return
|
||||
}
|
||||
let hasFiniteFrame =
|
||||
frameInHost.origin.x.isFinite &&
|
||||
frameInHost.origin.y.isFinite &&
|
||||
frameInHost.size.width.isFinite &&
|
||||
frameInHost.size.height.isFinite
|
||||
let clampedFrame = frameInHost.intersection(hostBounds)
|
||||
let hasVisibleIntersection =
|
||||
!clampedFrame.isNull &&
|
||||
clampedFrame.width > 1 &&
|
||||
clampedFrame.height > 1
|
||||
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
|
||||
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
|
||||
let tinyFrame =
|
||||
frameInHost.width <= Self.tinyHideThreshold ||
|
||||
frameInHost.height <= Self.tinyHideThreshold
|
||||
targetFrame.width <= Self.tinyHideThreshold ||
|
||||
targetFrame.height <= Self.tinyHideThreshold
|
||||
let revealReadyForDisplay =
|
||||
frameInHost.width >= Self.minimumRevealWidth &&
|
||||
frameInHost.height >= Self.minimumRevealHeight
|
||||
let outsideHostBounds = !frameInHost.intersects(hostView.bounds)
|
||||
targetFrame.width >= Self.minimumRevealWidth &&
|
||||
targetFrame.height >= Self.minimumRevealHeight
|
||||
let outsideHostBounds = !hasVisibleIntersection
|
||||
let shouldHide =
|
||||
!entry.visibleInUI ||
|
||||
anchorHidden ||
|
||||
|
|
@ -907,17 +1167,26 @@ final class WindowTerminalPortal: NSObject {
|
|||
|
||||
let oldFrame = hostedView.frame
|
||||
#if DEBUG
|
||||
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
|
||||
if frameWasClamped {
|
||||
dlog(
|
||||
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
|
||||
"anchor=\(portalDebugToken(anchorView)) " +
|
||||
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
}
|
||||
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
|
||||
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
|
||||
if collapsedToTiny {
|
||||
dlog(
|
||||
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
|
||||
)
|
||||
} else if restoredFromTiny {
|
||||
dlog(
|
||||
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
|
||||
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
|
@ -931,23 +1200,29 @@ final class WindowTerminalPortal: NSObject {
|
|||
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
|
||||
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
||||
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = true
|
||||
}
|
||||
|
||||
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
|
||||
if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = frameInHost
|
||||
hostedView.frame = targetFrame
|
||||
CATransaction.commit()
|
||||
hostedView.reconcileGeometryNow()
|
||||
hostedView.refreshSurfaceNow()
|
||||
}
|
||||
|
||||
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
|
||||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5,
|
||||
!shouldHide,
|
||||
(!hostedView.isHidden || revealReadyForDisplay) {
|
||||
hostedView.reconcileGeometryNow()
|
||||
if hasFiniteFrame {
|
||||
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
|
||||
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.bounds = expectedBounds
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -968,12 +1243,25 @@ final class WindowTerminalPortal: NSObject {
|
|||
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
|
||||
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
|
||||
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
|
||||
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
|
||||
"host=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
hostedView.isHidden = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
|
||||
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
|
||||
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
|
||||
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
|
||||
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
|
||||
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
|
||||
"hostBounds=\(portalDebugFrame(hostBounds))"
|
||||
)
|
||||
#endif
|
||||
|
||||
ensureDividerOverlayOnTop()
|
||||
}
|
||||
|
||||
|
|
@ -1007,6 +1295,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
}
|
||||
|
||||
func tearDown() {
|
||||
removeGeometryObservers()
|
||||
for hostedId in Array(entriesByHostedId.keys) {
|
||||
detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,58 @@ import SwiftUI
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import Combine
|
||||
import CoreText
|
||||
|
||||
func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String {
|
||||
switch context {
|
||||
case GHOSTTY_SURFACE_CONTEXT_WINDOW:
|
||||
return "window"
|
||||
case GHOSTTY_SURFACE_CONTEXT_TAB:
|
||||
return "tab"
|
||||
case GHOSTTY_SURFACE_CONTEXT_SPLIT:
|
||||
return "split"
|
||||
default:
|
||||
return "unknown(\(context))"
|
||||
}
|
||||
}
|
||||
|
||||
func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? {
|
||||
guard let quicklookFont = ghostty_surface_quicklook_font(surface) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue()
|
||||
let points = Float(CTFontGetSize(ctFont))
|
||||
guard points > 0 else { return nil }
|
||||
return points
|
||||
}
|
||||
|
||||
func cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: ghostty_surface_t,
|
||||
context: ghostty_surface_context_e
|
||||
) -> ghostty_surface_config_s {
|
||||
let inherited = ghostty_surface_inherited_config(sourceSurface, context)
|
||||
var config = inherited
|
||||
|
||||
// Make runtime zoom inheritance explicit, even when Ghostty's
|
||||
// inherit-font-size config is disabled.
|
||||
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
|
||||
if let points = runtimePoints {
|
||||
config.font_size = points
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let inheritedText = String(format: "%.2f", inherited.font_size)
|
||||
let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil"
|
||||
let finalText = String(format: "%.2f", config.font_size)
|
||||
dlog(
|
||||
"zoom.inherit context=\(cmuxSurfaceContextName(context)) " +
|
||||
"inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)"
|
||||
)
|
||||
#endif
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
struct SidebarStatusEntry {
|
||||
let key: String
|
||||
|
|
@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels)
|
||||
private var isProgrammaticSplit = false
|
||||
|
||||
/// Last terminal panel used as an inheritance source (typically last focused terminal).
|
||||
private var lastTerminalConfigInheritancePanelId: UUID?
|
||||
/// Last known terminal font points from inheritance sources. Used as fallback when
|
||||
/// no live terminal surface is currently available.
|
||||
private var lastTerminalConfigInheritanceFontPoints: Float?
|
||||
/// Per-panel inherited zoom lineage. Descendants reuse this root value unless
|
||||
/// a panel is explicitly re-zoomed by the user.
|
||||
private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:]
|
||||
|
||||
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
|
||||
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
|
||||
|
||||
|
|
@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) {
|
||||
init(
|
||||
title: String = "Terminal",
|
||||
workingDirectory: String? = nil,
|
||||
portOrdinal: Int = 0,
|
||||
configTemplate: ghostty_surface_config_s? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.portOrdinal = portOrdinal
|
||||
self.processTitle = title
|
||||
|
|
@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
let terminalPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB,
|
||||
configTemplate: configTemplate,
|
||||
workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
panels[terminalPanel.id] = terminalPanel
|
||||
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate)
|
||||
|
||||
// Create initial tab in bonsplit and store the mapping
|
||||
var initialTabId: TabID?
|
||||
|
|
@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
// MARK: - Panel Operations
|
||||
|
||||
private func seedTerminalInheritanceFontPoints(
|
||||
panelId: UUID,
|
||||
configTemplate: ghostty_surface_config_s?
|
||||
) {
|
||||
guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return }
|
||||
terminalInheritanceFontPointsByPanelId[panelId] = fontPoints
|
||||
lastTerminalConfigInheritanceFontPoints = fontPoints
|
||||
}
|
||||
|
||||
private func resolvedTerminalInheritanceFontPoints(
|
||||
for terminalPanel: TerminalPanel,
|
||||
sourceSurface: ghostty_surface_t,
|
||||
inheritedConfig: ghostty_surface_config_s
|
||||
) -> Float? {
|
||||
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface)
|
||||
if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 {
|
||||
if let runtimePoints, abs(runtimePoints - rooted) > 0.05 {
|
||||
// Runtime zoom changed after lineage was seeded (manual zoom on descendant);
|
||||
// treat runtime as the new root for future descendants.
|
||||
return runtimePoints
|
||||
}
|
||||
return rooted
|
||||
}
|
||||
if inheritedConfig.font_size > 0 {
|
||||
return inheritedConfig.font_size
|
||||
}
|
||||
return runtimePoints
|
||||
}
|
||||
|
||||
private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) {
|
||||
lastTerminalConfigInheritancePanelId = terminalPanel.id
|
||||
if let sourceSurface = terminalPanel.surface.surface,
|
||||
let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) {
|
||||
let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id]
|
||||
if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 {
|
||||
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints
|
||||
}
|
||||
lastTerminalConfigInheritanceFontPoints =
|
||||
terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints
|
||||
}
|
||||
}
|
||||
|
||||
func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? {
|
||||
guard let panelId = lastTerminalConfigInheritancePanelId else { return nil }
|
||||
return terminalPanel(for: panelId)
|
||||
}
|
||||
|
||||
func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? {
|
||||
lastTerminalConfigInheritanceFontPoints
|
||||
}
|
||||
|
||||
/// Candidate terminal panels used as the source when creating inherited Ghostty config.
|
||||
/// Preference order:
|
||||
/// 1) explicitly preferred terminal panel (when the caller has one),
|
||||
/// 2) selected terminal in the target pane,
|
||||
/// 3) currently focused terminal in the workspace,
|
||||
/// 4) last remembered terminal source,
|
||||
/// 5) first terminal tab in the target pane,
|
||||
/// 6) deterministic workspace fallback.
|
||||
private func terminalPanelConfigInheritanceCandidates(
|
||||
preferredPanelId: UUID? = nil,
|
||||
inPane preferredPaneId: PaneID? = nil
|
||||
) -> [TerminalPanel] {
|
||||
var candidates: [TerminalPanel] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
func appendCandidate(_ panel: TerminalPanel?) {
|
||||
guard let panel, seen.insert(panel.id).inserted else { return }
|
||||
candidates.append(panel)
|
||||
}
|
||||
|
||||
if let preferredPanelId,
|
||||
let terminalPanel = terminalPanel(for: preferredPanelId) {
|
||||
appendCandidate(terminalPanel)
|
||||
}
|
||||
|
||||
if let preferredPaneId,
|
||||
let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id,
|
||||
let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId),
|
||||
let selectedTerminalPanel = terminalPanel(for: selectedPanelId) {
|
||||
appendCandidate(selectedTerminalPanel)
|
||||
}
|
||||
|
||||
if let focusedTerminalPanel {
|
||||
appendCandidate(focusedTerminalPanel)
|
||||
}
|
||||
|
||||
if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() {
|
||||
appendCandidate(rememberedTerminalPanel)
|
||||
}
|
||||
|
||||
if let preferredPaneId {
|
||||
for tab in bonsplitController.tabs(inPane: preferredPaneId) {
|
||||
guard let panelId = panelIdFromSurfaceId(tab.id),
|
||||
let terminalPanel = terminalPanel(for: panelId) else { continue }
|
||||
appendCandidate(terminalPanel)
|
||||
}
|
||||
}
|
||||
|
||||
for terminalPanel in panels.values
|
||||
.compactMap({ $0 as? TerminalPanel })
|
||||
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
|
||||
appendCandidate(terminalPanel)
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/// Picks the first terminal panel candidate used as the inheritance source.
|
||||
func terminalPanelForConfigInheritance(
|
||||
preferredPanelId: UUID? = nil,
|
||||
inPane preferredPaneId: PaneID? = nil
|
||||
) -> TerminalPanel? {
|
||||
terminalPanelConfigInheritanceCandidates(
|
||||
preferredPanelId: preferredPanelId,
|
||||
inPane: preferredPaneId
|
||||
).first
|
||||
}
|
||||
|
||||
private func inheritedTerminalConfig(
|
||||
preferredPanelId: UUID? = nil,
|
||||
inPane preferredPaneId: PaneID? = nil
|
||||
) -> ghostty_surface_config_s? {
|
||||
// Walk candidates in priority order and use the first panel with a live surface.
|
||||
// This avoids returning nil when the top candidate exists but is not attached yet.
|
||||
for terminalPanel in terminalPanelConfigInheritanceCandidates(
|
||||
preferredPanelId: preferredPanelId,
|
||||
inPane: preferredPaneId
|
||||
) {
|
||||
guard let sourceSurface = terminalPanel.surface.surface else { continue }
|
||||
var config = cmuxInheritedSurfaceConfig(
|
||||
sourceSurface: sourceSurface,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT
|
||||
)
|
||||
if let rootedFontPoints = resolvedTerminalInheritanceFontPoints(
|
||||
for: terminalPanel,
|
||||
sourceSurface: sourceSurface,
|
||||
inheritedConfig: config
|
||||
), rootedFontPoints > 0 {
|
||||
config.font_size = rootedFontPoints
|
||||
terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints
|
||||
}
|
||||
rememberTerminalConfigInheritanceSource(terminalPanel)
|
||||
if config.font_size > 0 {
|
||||
lastTerminalConfigInheritanceFontPoints = config.font_size
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints {
|
||||
var config = ghostty_surface_config_new()
|
||||
config.font_size = fallbackFontPoints
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))"
|
||||
)
|
||||
#endif
|
||||
return config
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Create a new split with a terminal panel
|
||||
@discardableResult
|
||||
func newTerminalSplit(
|
||||
|
|
@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
insertFirst: Bool = false,
|
||||
focus: Bool = true
|
||||
) -> TerminalPanel? {
|
||||
// Get inherited config from the source terminal when possible.
|
||||
// If the split is initiated from a non-terminal panel (for example browser),
|
||||
// fall back to any terminal in the workspace.
|
||||
let inheritedConfig: ghostty_surface_config_s? = {
|
||||
if let sourceTerminal = terminalPanel(for: panelId),
|
||||
let existing = sourceTerminal.surface.surface {
|
||||
return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
||||
}
|
||||
if let fallbackSurface = panels.values
|
||||
.compactMap({ ($0 as? TerminalPanel)?.surface.surface })
|
||||
.first {
|
||||
return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// Find the pane containing the source panel
|
||||
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
|
||||
var sourcePaneId: PaneID?
|
||||
|
|
@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
guard let paneId = sourcePaneId else { return nil }
|
||||
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
||||
|
||||
// Create the new terminal panel.
|
||||
let newPanel = TerminalPanel(
|
||||
|
|
@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
||||
// Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit
|
||||
// mutates layout state (avoids transient "Empty Panel" flashes during split).
|
||||
|
|
@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
panels.removeValue(forKey: newPanel.id)
|
||||
panelTitles.removeValue(forKey: newPanel.id)
|
||||
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
||||
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? {
|
||||
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
||||
|
||||
// Get an existing terminal panel to inherit config from
|
||||
let inheritedConfig: ghostty_surface_config_s? = {
|
||||
for panel in panels.values {
|
||||
if let terminalPanel = panel as? TerminalPanel,
|
||||
let surface = terminalPanel.surface.surface {
|
||||
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let inheritedConfig = inheritedTerminalConfig(inPane: paneId)
|
||||
|
||||
// Create new terminal panel
|
||||
let newPanel = TerminalPanel(
|
||||
|
|
@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
||||
// Create tab in bonsplit
|
||||
guard let newTabId = bonsplitController.createTab(
|
||||
|
|
@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
) else {
|
||||
panels.removeValue(forKey: newPanel.id)
|
||||
panelTitles.removeValue(forKey: newPanel.id)
|
||||
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// Create a new terminal panel (used when replacing the last panel)
|
||||
@discardableResult
|
||||
func createReplacementTerminalPanel() -> TerminalPanel {
|
||||
let inheritedConfig = inheritedTerminalConfig(
|
||||
preferredPanelId: focusedPanelId,
|
||||
inPane: bonsplitController.focusedPaneId
|
||||
)
|
||||
let newPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_TAB,
|
||||
configTemplate: nil,
|
||||
configTemplate: inheritedConfig,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
||||
// Create tab in bonsplit
|
||||
if let newTabId = bonsplitController.createTab(
|
||||
|
|
@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate {
|
|||
}
|
||||
|
||||
panel.focus()
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
rememberTerminalConfigInheritanceSource(terminalPanel)
|
||||
}
|
||||
let isManuallyUnread = manualUnreadPanelIds.contains(panelId)
|
||||
let markedAt = manualUnreadMarkedAt[panelId]
|
||||
if Self.shouldClearManualUnread(
|
||||
|
|
@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate {
|
|||
panelSubscriptions.removeValue(forKey: panelId)
|
||||
surfaceTTYNames.removeValue(forKey: panelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
|
||||
terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
|
||||
if lastTerminalConfigInheritancePanelId == panelId {
|
||||
lastTerminalConfigInheritancePanelId = nil
|
||||
}
|
||||
|
||||
// Keep the workspace invariant: always retain at least one real panel.
|
||||
// This prevents runtime close callbacks from ever collapsing into a tabless workspace.
|
||||
|
|
@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate {
|
|||
// Keep the existing placeholder tab identity and replace only the panel mapping.
|
||||
// This avoids an extra create+close tab churn that can transiently render an
|
||||
// empty pane during drag-to-split of a single-tab pane.
|
||||
let inheritedConfig: ghostty_surface_config_s? = {
|
||||
for panel in panels.values {
|
||||
if let terminalPanel = panel as? TerminalPanel,
|
||||
let surface = terminalPanel.surface.surface {
|
||||
return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let inheritedConfig = inheritedTerminalConfig(inPane: originalPane)
|
||||
|
||||
let replacementPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
|
|
@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate {
|
|||
)
|
||||
panels[replacementPanel.id] = replacementPanel
|
||||
panelTitles[replacementPanel.id] = replacementPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig)
|
||||
surfaceIdToPanelId[replacementTab.id] = replacementPanel.id
|
||||
|
||||
bonsplitController.updateTab(
|
||||
|
|
@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate {
|
|||
// Get the focused terminal in the original pane to inherit config from
|
||||
guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id,
|
||||
let sourcePanelId = panelIdFromSurfaceId(sourceTabId),
|
||||
let sourcePanel = terminalPanel(for: sourcePanelId) else { return }
|
||||
terminalPanel(for: sourcePanelId) != nil else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate {
|
|||
)
|
||||
#endif
|
||||
|
||||
let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface {
|
||||
ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
let inheritedConfig = inheritedTerminalConfig(
|
||||
preferredPanelId: sourcePanelId,
|
||||
inPane: originalPane
|
||||
)
|
||||
|
||||
let newPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
|
|
@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate {
|
|||
)
|
||||
panels[newPanel.id] = newPanel
|
||||
panelTitles[newPanel.id] = newPanel.displayTitle
|
||||
seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig)
|
||||
|
||||
guard let newTabId = bonsplitController.createTab(
|
||||
title: newPanel.displayTitle,
|
||||
|
|
@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate {
|
|||
) else {
|
||||
panels.removeValue(forKey: newPanel.id)
|
||||
panelTitles.removeValue(forKey: newPanel.id)
|
||||
terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
|
||||
activeTabManager.addTab()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,7 +392,7 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
Button("Reopen Closed Browser Panel") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
|
||||
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
|
||||
}
|
||||
.keyboardShortcut("t", modifiers: [.command, .shift])
|
||||
}
|
||||
|
|
@ -401,35 +401,35 @@ struct cmuxApp: App {
|
|||
CommandGroup(after: .textEditing) {
|
||||
Menu("Find") {
|
||||
Button("Find…") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
|
||||
activeTabManager.startSearch()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
|
||||
Button("Find Next") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
|
||||
activeTabManager.findNext()
|
||||
}
|
||||
.keyboardShortcut("g", modifiers: .command)
|
||||
|
||||
Button("Find Previous") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
|
||||
activeTabManager.findPrevious()
|
||||
}
|
||||
.keyboardShortcut("g", modifiers: [.command, .shift])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Hide Find Bar") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
|
||||
activeTabManager.hideFind()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: [.command, .shift])
|
||||
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
|
||||
.disabled(!(activeTabManager.isFindVisible))
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Use Selection for Find") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
|
||||
activeTabManager.searchSelection()
|
||||
}
|
||||
.keyboardShortcut("e", modifiers: .command)
|
||||
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
|
||||
.disabled(!(activeTabManager.canUseSelectionForFind))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -444,54 +444,54 @@ struct cmuxApp: App {
|
|||
Divider()
|
||||
|
||||
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
|
||||
activeTabManager.selectNextSurface()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
|
||||
activeTabManager.selectPreviousSurface()
|
||||
}
|
||||
|
||||
Button("Back") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack()
|
||||
activeTabManager.focusedBrowserPanel?.goBack()
|
||||
}
|
||||
.keyboardShortcut("[", modifiers: .command)
|
||||
|
||||
Button("Forward") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward()
|
||||
activeTabManager.focusedBrowserPanel?.goForward()
|
||||
}
|
||||
.keyboardShortcut("]", modifiers: .command)
|
||||
|
||||
Button("Reload Page") {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
|
||||
activeTabManager.focusedBrowserPanel?.reload()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
|
||||
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if !manager.toggleDeveloperToolsFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if !manager.showJavaScriptConsoleFocusedBrowser() {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
Button("Zoom In") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
|
||||
_ = activeTabManager.zoomInFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("=", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser()
|
||||
_ = activeTabManager.zoomOutFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
|
||||
Button("Actual Size") {
|
||||
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser()
|
||||
_ = activeTabManager.resetZoomFocusedBrowser()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
|
||||
|
|
@ -500,11 +500,11 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
|
||||
activeTabManager.selectNextTab()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
|
||||
activeTabManager.selectPreviousTab()
|
||||
}
|
||||
|
||||
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
|
||||
|
|
@ -534,7 +534,7 @@ struct cmuxApp: App {
|
|||
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
||||
ForEach(1...9, id: \.self) { number in
|
||||
Button("Workspace \(number)") {
|
||||
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
||||
let manager = activeTabManager
|
||||
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
|
||||
manager.selectTab(at: targetIndex)
|
||||
}
|
||||
|
|
@ -705,6 +705,12 @@ struct cmuxApp: App {
|
|||
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
||||
}
|
||||
|
||||
private var activeTabManager: TabManager {
|
||||
AppDelegate.shared?.synchronizeActiveMainWindowContext(
|
||||
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
|
||||
) ?? tabManager
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
|
|
@ -756,11 +762,11 @@ struct cmuxApp: App {
|
|||
window.performClose(nil)
|
||||
return
|
||||
}
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
|
||||
activeTabManager.closeCurrentPanelWithConfirmation()
|
||||
}
|
||||
|
||||
private func closeTabOrWindow() {
|
||||
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
|
||||
activeTabManager.closeCurrentTabWithConfirmation()
|
||||
}
|
||||
|
||||
private func showNotificationsPopover() {
|
||||
|
|
|
|||
|
|
@ -376,6 +376,127 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
||||
private func makeMainWindow(id: UUID) -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)")
|
||||
return window
|
||||
}
|
||||
|
||||
func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() {
|
||||
_ = NSApplication.shared
|
||||
let app = AppDelegate()
|
||||
|
||||
let windowAId = UUID()
|
||||
let windowBId = UUID()
|
||||
let windowA = makeMainWindow(id: windowAId)
|
||||
let windowB = makeMainWindow(id: windowBId)
|
||||
defer {
|
||||
windowA.orderOut(nil)
|
||||
windowB.orderOut(nil)
|
||||
}
|
||||
|
||||
let managerA = TabManager()
|
||||
let managerB = TabManager()
|
||||
app.registerMainWindow(
|
||||
windowA,
|
||||
windowId: windowAId,
|
||||
tabManager: managerA,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
app.registerMainWindow(
|
||||
windowB,
|
||||
windowId: windowBId,
|
||||
tabManager: managerB,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
|
||||
windowB.makeKeyAndOrderFront(nil)
|
||||
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB)
|
||||
XCTAssertTrue(app.tabManager === managerB)
|
||||
|
||||
windowA.makeKeyAndOrderFront(nil)
|
||||
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
||||
XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager")
|
||||
XCTAssertTrue(app.tabManager === managerA)
|
||||
}
|
||||
|
||||
func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() {
|
||||
_ = NSApplication.shared
|
||||
let app = AppDelegate()
|
||||
|
||||
let windowAId = UUID()
|
||||
let windowBId = UUID()
|
||||
let windowA = makeMainWindow(id: windowAId)
|
||||
let windowB = makeMainWindow(id: windowBId)
|
||||
defer {
|
||||
windowA.orderOut(nil)
|
||||
windowB.orderOut(nil)
|
||||
}
|
||||
|
||||
let managerA = TabManager()
|
||||
let managerB = TabManager()
|
||||
app.registerMainWindow(
|
||||
windowA,
|
||||
windowId: windowAId,
|
||||
tabManager: managerA,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
app.registerMainWindow(
|
||||
windowB,
|
||||
windowId: windowBId,
|
||||
tabManager: managerB,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
|
||||
// Seed active manager and clear focus windows to force fallback routing.
|
||||
windowA.makeKeyAndOrderFront(nil)
|
||||
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
||||
XCTAssertTrue(app.tabManager === managerA)
|
||||
windowA.orderOut(nil)
|
||||
windowB.orderOut(nil)
|
||||
|
||||
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil)
|
||||
XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window")
|
||||
XCTAssertTrue(app.tabManager === managerA)
|
||||
}
|
||||
|
||||
func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() {
|
||||
_ = NSApplication.shared
|
||||
let app = AppDelegate()
|
||||
|
||||
let windowId = UUID()
|
||||
let window = makeMainWindow(id: windowId)
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
let manager = TabManager()
|
||||
app.registerMainWindow(
|
||||
window,
|
||||
windowId: windowId,
|
||||
tabManager: manager,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
|
||||
// SwiftUI can replace the NSWindow identifier string at runtime.
|
||||
window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged")
|
||||
|
||||
let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window)
|
||||
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
|
||||
XCTAssertTrue(app.tabManager === manager)
|
||||
}
|
||||
}
|
||||
|
||||
final class FocusFlashPatternTests: XCTestCase {
|
||||
func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
|
||||
XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0])
|
||||
|
|
@ -1048,6 +1169,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testArrowNavigationDeltaIgnoresCapsLockModifier() {
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForArrowNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.capsLock],
|
||||
keyCode: 126
|
||||
),
|
||||
-1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForArrowNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.capsLock],
|
||||
keyCode: 125
|
||||
),
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() {
|
||||
XCTAssertNil(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
|
|
@ -1101,6 +1241,33 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase {
|
|||
1
|
||||
)
|
||||
}
|
||||
|
||||
func testCommandNavigationDeltaIgnoresCapsLockModifier() {
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.control, .capsLock],
|
||||
chars: "n"
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.command, .capsLock],
|
||||
chars: "p"
|
||||
),
|
||||
-1
|
||||
)
|
||||
}
|
||||
|
||||
func testSubmitOnReturnIgnoresCapsLockModifier() {
|
||||
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: []))
|
||||
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift]))
|
||||
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock]))
|
||||
XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock]))
|
||||
XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock]))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserZoomShortcutActionTests: XCTestCase {
|
||||
|
|
@ -1117,6 +1284,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase {
|
|||
browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24),
|
||||
.zoomIn
|
||||
)
|
||||
XCTAssertEqual(
|
||||
browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30),
|
||||
.zoomIn
|
||||
)
|
||||
}
|
||||
|
||||
func testZoomOutSupportsMinusAndUnderscoreVariants() {
|
||||
|
|
@ -1195,6 +1366,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class GhosttyResponderResolutionTests: XCTestCase {
|
||||
private final class FocusProbeView: NSView {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
func testResolvesGhosttyViewFromDescendantResponder() {
|
||||
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||
let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
|
||||
ghosttyView.addSubview(descendant)
|
||||
|
||||
XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView)
|
||||
}
|
||||
|
||||
func testResolvesGhosttyViewFromGhosttyResponder() {
|
||||
let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||
XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView)
|
||||
}
|
||||
|
||||
func testReturnsNilForUnrelatedResponder() {
|
||||
let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
|
||||
XCTAssertNil(cmuxOwningGhosttyView(for: view))
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteKeyboardNavigationTests: XCTestCase {
|
||||
func testArrowKeysMoveSelectionWithoutModifiers() {
|
||||
XCTAssertEqual(
|
||||
|
|
@ -1342,115 +1537,34 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
|
|||
}
|
||||
|
||||
final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
|
||||
func testFirstEntryAlwaysPinsToTopWhenScrollable() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
func testFirstEntryPinsToTopAnchor() {
|
||||
let anchor = ContentView.commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: 0,
|
||||
previousIndex: 1,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
resultCount: 20
|
||||
)
|
||||
XCTAssertEqual(anchor, .top)
|
||||
XCTAssertEqual(anchor, UnitPoint.top)
|
||||
}
|
||||
|
||||
func testLastEntryAlwaysPinsToBottomWhenScrollable() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
func testLastEntryPinsToBottomAnchor() {
|
||||
let anchor = ContentView.commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: 19,
|
||||
previousIndex: 18,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
resultCount: 20
|
||||
)
|
||||
XCTAssertEqual(anchor, .bottom)
|
||||
XCTAssertEqual(anchor, UnitPoint.bottom)
|
||||
}
|
||||
|
||||
func testFullyVisibleMiddleEntryDoesNotScroll() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
func testMiddleEntryUsesNilAnchorForMinimalScroll() {
|
||||
let anchor = ContentView.commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: 6,
|
||||
previousIndex: 5,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
resultCount: 20
|
||||
)
|
||||
XCTAssertNil(anchor)
|
||||
}
|
||||
|
||||
func testOutOfViewMiddleEntryUsesDirectionForAnchor() {
|
||||
let downAnchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 9,
|
||||
previousIndex: 8,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(downAnchor, .bottom)
|
||||
|
||||
let upAnchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 8,
|
||||
previousIndex: 9,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(upAnchor, .top)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase {
|
||||
func testTopEdgeReturnsTopWhenNotPinned() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
func testEmptyResultsProduceNoAnchor() {
|
||||
let anchor = ContentView.commandPaletteScrollPositionAnchor(
|
||||
selectedIndex: 0,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .top)
|
||||
}
|
||||
|
||||
func testBottomEdgeReturnsBottomWhenNotPinned() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 19,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .bottom)
|
||||
}
|
||||
|
||||
func testPinnedTopAndBottomReturnNil() {
|
||||
let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 0,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(topAnchor)
|
||||
|
||||
let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 19,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(bottomAnchor)
|
||||
}
|
||||
|
||||
func testMiddleSelectionNeverForcesCorrection() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 8,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
resultCount: 0
|
||||
)
|
||||
XCTAssertNil(anchor)
|
||||
}
|
||||
|
|
@ -2192,6 +2306,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
|
||||
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let leftPanelId = workspace.focusedPanelId,
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
|
||||
let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else {
|
||||
XCTFail("Expected workspace split setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
// Programmatic split focuses the new right panel by default.
|
||||
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id)
|
||||
|
||||
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId)
|
||||
XCTAssertEqual(
|
||||
sourcePanel?.id,
|
||||
leftPanelId,
|
||||
"Expected inheritance to use the selected terminal in the target pane"
|
||||
)
|
||||
}
|
||||
|
||||
func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanelId = workspace.focusedPanelId,
|
||||
let paneId = workspace.paneId(forPanelId: terminalPanelId),
|
||||
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
|
||||
XCTFail("Expected workspace browser setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
|
||||
|
||||
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId)
|
||||
XCTAssertEqual(
|
||||
sourcePanel?.id,
|
||||
terminalPanelId,
|
||||
"Expected inheritance to fall back to a terminal in the pane when browser is selected"
|
||||
)
|
||||
}
|
||||
|
||||
func testPreferredTerminalPanelWinsWhenProvided() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace with a terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId)
|
||||
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
|
||||
}
|
||||
|
||||
func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let leftTerminalPanelId = workspace.focusedPanelId,
|
||||
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
|
||||
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
|
||||
XCTFail("Expected split setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.focusPanel(leftTerminalPanelId)
|
||||
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
|
||||
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
|
||||
|
||||
let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId)
|
||||
XCTAssertEqual(
|
||||
sourcePanel?.id,
|
||||
leftTerminalPanelId,
|
||||
"Expected inheritance to prefer last focused terminal when browser is focused in another pane"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase {
|
||||
func testUsesFocusedTerminalWhenTerminalIsFocused() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected selected workspace with focused terminal")
|
||||
return
|
||||
}
|
||||
|
||||
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
||||
XCTAssertEqual(sourcePanel?.id, terminalPanelId)
|
||||
}
|
||||
|
||||
func testFallsBackToTerminalWhenBrowserIsFocused() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanelId = workspace.focusedPanelId,
|
||||
let paneId = workspace.paneId(forPanelId: terminalPanelId),
|
||||
let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else {
|
||||
XCTFail("Expected selected workspace setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(workspace.focusedPanelId, browserPanel.id)
|
||||
|
||||
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
||||
XCTAssertEqual(
|
||||
sourcePanel?.id,
|
||||
terminalPanelId,
|
||||
"Expected new workspace inheritance source to resolve to the pane terminal when browser is focused"
|
||||
)
|
||||
}
|
||||
|
||||
func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let leftTerminalPanelId = workspace.focusedPanelId,
|
||||
let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal),
|
||||
let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else {
|
||||
XCTFail("Expected split setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
workspace.focusPanel(leftTerminalPanelId)
|
||||
_ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true)
|
||||
XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId)
|
||||
|
||||
let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource()
|
||||
XCTAssertEqual(
|
||||
sourcePanel?.id,
|
||||
leftTerminalPanelId,
|
||||
"Expected workspace inheritance source to use last focused terminal across panes"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
|
||||
func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() {
|
||||
|
|
@ -3008,6 +3257,63 @@ final class FinderServicePathResolverTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
|
||||
private func environment(
|
||||
existingPaths: Set<String>,
|
||||
homeDirectoryPath: String = "/Users/tester"
|
||||
) -> TerminalDirectoryOpenTarget.DetectionEnvironment {
|
||||
TerminalDirectoryOpenTarget.DetectionEnvironment(
|
||||
homeDirectoryPath: homeDirectoryPath,
|
||||
fileExistsAtPath: { existingPaths.contains($0) }
|
||||
)
|
||||
}
|
||||
|
||||
func testAvailableTargetsDetectSystemApplications() {
|
||||
let env = environment(
|
||||
existingPaths: [
|
||||
"/Applications/Visual Studio Code.app",
|
||||
"/System/Library/CoreServices/Finder.app",
|
||||
"/System/Applications/Utilities/Terminal.app",
|
||||
"/Applications/Zed Preview.app",
|
||||
]
|
||||
)
|
||||
|
||||
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
|
||||
XCTAssertTrue(availableTargets.contains(.vscode))
|
||||
XCTAssertTrue(availableTargets.contains(.finder))
|
||||
XCTAssertTrue(availableTargets.contains(.terminal))
|
||||
XCTAssertTrue(availableTargets.contains(.zed))
|
||||
XCTAssertFalse(availableTargets.contains(.cursor))
|
||||
}
|
||||
|
||||
func testAvailableTargetsFallbackToUserApplications() {
|
||||
let env = environment(
|
||||
existingPaths: [
|
||||
"/Users/tester/Applications/Cursor.app",
|
||||
"/Users/tester/Applications/Warp.app",
|
||||
"/Users/tester/Applications/Android Studio.app",
|
||||
]
|
||||
)
|
||||
|
||||
let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env)
|
||||
XCTAssertTrue(availableTargets.contains(.cursor))
|
||||
XCTAssertTrue(availableTargets.contains(.warp))
|
||||
XCTAssertTrue(availableTargets.contains(.androidStudio))
|
||||
XCTAssertFalse(availableTargets.contains(.vscode))
|
||||
}
|
||||
|
||||
func testITerm2DetectsLegacyBundleName() {
|
||||
let env = environment(existingPaths: ["/Applications/iTerm.app"])
|
||||
XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env))
|
||||
}
|
||||
|
||||
func testCommandPaletteShortcutsExcludeGenericIDEEntry() {
|
||||
let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets
|
||||
XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" }))
|
||||
XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" }))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserSearchEngineTests: XCTestCase {
|
||||
func testGoogleSearchURL() throws {
|
||||
let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))
|
||||
|
|
@ -4287,6 +4593,50 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
XCTAssertTrue(state.isHidden)
|
||||
}
|
||||
|
||||
func testWindowResignKeyClearsFocusedTerminalFirstResponder() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let hostedView = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120))
|
||||
)
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
hostedView.setVisibleInUI(true)
|
||||
hostedView.setActive(true)
|
||||
hostedView.moveFocus()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertTrue(
|
||||
hostedView.isSurfaceViewFirstResponder(),
|
||||
"Expected terminal surface to be first responder before window blur"
|
||||
)
|
||||
|
||||
NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertFalse(
|
||||
hostedView.isSurfaceViewFirstResponder(),
|
||||
"Window blur should force terminal surface to resign first responder"
|
||||
)
|
||||
}
|
||||
|
||||
func testSearchOverlayMountsAndUnmountsWithSearchState() {
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
|
|
@ -4998,6 +5348,38 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserExternalNavigationSchemeTests: XCTestCase {
|
||||
func testCustomAppSchemesOpenExternally() throws {
|
||||
let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
|
||||
let slack = try XCTUnwrap(URL(string: "slack://open"))
|
||||
let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join"))
|
||||
let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com"))
|
||||
|
||||
XCTAssertTrue(browserShouldOpenURLExternally(discord))
|
||||
XCTAssertTrue(browserShouldOpenURLExternally(slack))
|
||||
XCTAssertTrue(browserShouldOpenURLExternally(zoom))
|
||||
XCTAssertTrue(browserShouldOpenURLExternally(mailto))
|
||||
}
|
||||
|
||||
func testEmbeddedBrowserSchemesStayInWebView() throws {
|
||||
let https = try XCTUnwrap(URL(string: "https://example.com"))
|
||||
let http = try XCTUnwrap(URL(string: "http://example.com"))
|
||||
let about = try XCTUnwrap(URL(string: "about:blank"))
|
||||
let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
|
||||
let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
|
||||
let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
|
||||
let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
|
||||
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(https))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(http))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(about))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(data))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(blob))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(javascript))
|
||||
XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserHostWhitelistTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
|
|
|
|||
|
|
@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
|
||||
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
|
||||
}
|
||||
|
||||
func testSidebarResizerHasMaximumWidthCap() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
|
||||
|
||||
let elements = app.descendants(matching: .any)
|
||||
let resizer = elements["SidebarResizer"]
|
||||
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
|
||||
|
||||
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
|
||||
start.press(forDuration: 0.1, thenDragTo: farRight)
|
||||
|
||||
let windowFrame = window.frame
|
||||
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
|
||||
let minimumExpectedRemaining = windowFrame.width * 0.45
|
||||
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
remainingWidth,
|
||||
minimumExpectedRemaining,
|
||||
"Expected sidebar max-width clamp to leave substantial terminal width. " +
|
||||
"remaining=\(remainingWidth), window=\(windowFrame.width)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
tests/test_ci_self_hosted_guard.sh
Executable file
29
tests/test_ci_self_hosted_guard.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
# Regression test for https://github.com/manaflow-ai/cmux/issues/385.
|
||||
# Ensures self-hosted UI tests are never run for fork pull requests.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
|
||||
|
||||
EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"
|
||||
|
||||
if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE"
|
||||
echo "Expected line:"
|
||||
echo " $EXPECTED_IF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ ui-tests:/ { in_ui_tests=1; next }
|
||||
in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 }
|
||||
in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
|
||||
in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
|
||||
END { exit !(saw_self_hosted && saw_guard) }
|
||||
' "$WORKFLOW_FILE"; then
|
||||
echo "FAIL: ui-tests block must keep both self-hosted and fork guard"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: ui-tests self-hosted fork guard is present"
|
||||
106
tests/test_terminal_resize_portal_regressions.py
Normal file
106
tests/test_terminal_resize_portal_regressions.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
|
||||
|
||||
Guards the key invariants for issue #348:
|
||||
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
|
||||
2) Surface sizing must prefer live bounds over stale pending values when available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
|
||||
if "hostView.layer?.masksToBounds = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
|
||||
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
|
||||
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
|
||||
|
||||
if "private func synchronizeLayoutHierarchy()" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
|
||||
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
|
||||
if "hostedView.reconcileGeometryNow()" not in extract_block(
|
||||
portal_source,
|
||||
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
|
||||
):
|
||||
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
|
||||
|
||||
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
|
||||
for required in [
|
||||
"let hostBounds = hostView.bounds",
|
||||
"let clampedFrame = frameInHost.intersection(hostBounds)",
|
||||
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
|
||||
"scheduleDeferredFullSynchronizeAll()",
|
||||
"hostedView.reconcileGeometryNow()",
|
||||
"hostedView.refreshSurfaceNow()",
|
||||
]:
|
||||
if required not in sync_block:
|
||||
failures.append(f"terminal portal sync missing: {required}")
|
||||
|
||||
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
|
||||
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
|
||||
|
||||
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
|
||||
bounds_index = resolved_block.find("let currentBounds = bounds.size")
|
||||
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
|
||||
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
|
||||
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
|
||||
|
||||
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
|
||||
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
|
||||
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
|
||||
|
||||
if failures:
|
||||
print("FAIL: terminal resize/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: terminal resize/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d
|
||||
Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849
|
||||
Loading…
Add table
Add a link
Reference in a new issue