Merge branch 'main' into issue-151-ssh-remote-port-proxying
This commit is contained in:
commit
d67090994e
61 changed files with 11220 additions and 614 deletions
|
|
@ -307,6 +307,9 @@ final class VSCodeServeWebController {
|
|||
private var isLaunching = false
|
||||
private var activeLaunchGeneration: UInt64?
|
||||
private var lifecycleGeneration: UInt64 = 0
|
||||
#if DEBUG
|
||||
private var testingTrackedProcesses: [Process] = []
|
||||
#endif
|
||||
|
||||
private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) {
|
||||
self.launchProcessOverride = launchProcessOverride
|
||||
|
|
@ -318,6 +321,26 @@ final class VSCodeServeWebController {
|
|||
) -> VSCodeServeWebController {
|
||||
VSCodeServeWebController(launchProcessOverride: launchProcessOverride)
|
||||
}
|
||||
|
||||
func trackConnectionTokenFileForTesting(
|
||||
_ connectionTokenFileURL: URL,
|
||||
setAsLaunchingProcess: Bool = false,
|
||||
setAsServeWebProcess: Bool = false
|
||||
) {
|
||||
let process = Process()
|
||||
queue.sync {
|
||||
if setAsLaunchingProcess {
|
||||
self.launchingProcess = process
|
||||
}
|
||||
if setAsServeWebProcess {
|
||||
self.serveWebProcess = process
|
||||
}
|
||||
if !setAsLaunchingProcess && !setAsServeWebProcess {
|
||||
self.testingTrackedProcesses.append(process)
|
||||
}
|
||||
self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) {
|
||||
|
|
@ -420,6 +443,9 @@ final class VSCodeServeWebController {
|
|||
}
|
||||
self.serveWebProcess = nil
|
||||
self.launchingProcess = nil
|
||||
#if DEBUG
|
||||
self.testingTrackedProcesses.removeAll()
|
||||
#endif
|
||||
var tokenFileURLs = processes.compactMap {
|
||||
self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0))
|
||||
}
|
||||
|
|
@ -1490,6 +1516,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
struct ScriptableMainWindowState {
|
||||
let windowId: UUID
|
||||
let tabManager: TabManager
|
||||
let window: NSWindow?
|
||||
}
|
||||
|
||||
struct SessionDisplayGeometry {
|
||||
let displayID: UInt32?
|
||||
let frame: CGRect
|
||||
|
|
@ -3414,6 +3446,95 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
windowForMainWindowId(windowId)
|
||||
}
|
||||
|
||||
func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? {
|
||||
for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scriptableMainWindows() -> [ScriptableMainWindowState] {
|
||||
var results: [ScriptableMainWindowState] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
for window in NSApp.orderedWindows {
|
||||
guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue }
|
||||
guard seen.insert(context.windowId).inserted else { continue }
|
||||
results.append(
|
||||
ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let remaining = mainWindowContexts.values
|
||||
.sorted { $0.windowId.uuidString < $1.windowId.uuidString }
|
||||
.filter { seen.insert($0.windowId).inserted }
|
||||
|
||||
for context in remaining {
|
||||
results.append(
|
||||
ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? {
|
||||
guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else {
|
||||
return nil
|
||||
}
|
||||
return ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
}
|
||||
|
||||
func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? {
|
||||
guard let context = contextContainingTabId(tabId) else { return nil }
|
||||
return ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool {
|
||||
guard let state = scriptableMainWindow(windowId: windowId),
|
||||
let window = state.window else {
|
||||
return false
|
||||
}
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? {
|
||||
guard let state = scriptableMainWindow(windowId: windowId) else { return nil }
|
||||
if shouldBringToFront, let window = state.window {
|
||||
setActiveMainWindow(window)
|
||||
bringToFront(window)
|
||||
}
|
||||
let workspace = state.tabManager.addWorkspace(
|
||||
workingDirectory: workingDirectory,
|
||||
select: shouldBringToFront
|
||||
)
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
private func markCommandPaletteOpenRequested(for window: NSWindow?) {
|
||||
guard let window,
|
||||
let windowId = mainWindowId(for: window) else { return }
|
||||
|
|
@ -4785,6 +4906,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
updateController.checkForUpdates()
|
||||
}
|
||||
|
||||
func openWelcomeWorkspace() {
|
||||
guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else {
|
||||
return
|
||||
}
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
bringToFront(window)
|
||||
}
|
||||
let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false)
|
||||
sendWelcomeCommandWhenReady(to: workspace)
|
||||
}
|
||||
|
||||
func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) {
|
||||
sendTextWhenReady("cmux welcome\n", to: workspace) {
|
||||
if markShownOnSend {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applyUpdateIfAvailable(_ sender: Any?) {
|
||||
updateViewModel.overrideState = nil
|
||||
updateController.installUpdate()
|
||||
|
|
@ -4914,7 +5055,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
SettingsWindowController.shared.show(navigationTarget: target)
|
||||
},
|
||||
activateApplication: @MainActor () -> Void = {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
) {
|
||||
#if DEBUG
|
||||
|
|
@ -4922,6 +5063,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#endif
|
||||
showFallbackSettingsWindow(navigationTarget)
|
||||
activateApplication()
|
||||
if let window = SettingsWindowController.shared.window {
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
DispatchQueue.main.async {
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("settings.open.present activate=1")
|
||||
#endif
|
||||
|
|
@ -5019,6 +5168,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
pasteboard.setString(payload, forType: .string)
|
||||
}
|
||||
|
||||
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
|
||||
beforeSend?()
|
||||
terminalPanel.sendText(text)
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else {
|
||||
NSLog("Command send: surface not ready after \(maxAttempts) attempts")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private let debugColorWorkspaceTitlePrefix = "Debug Color - "
|
||||
private let debugPerfWorkspaceTitlePrefix = "Debug Perf - "
|
||||
|
|
@ -5352,21 +5517,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
|
||||
terminalPanel.sendText(text)
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else {
|
||||
NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func triggerSentryTestCrash(_ sender: Any?) {
|
||||
SentrySDK.crash()
|
||||
}
|
||||
|
|
@ -6129,6 +6279,80 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
writeGotoSplitTestData(updates)
|
||||
}
|
||||
|
||||
private func recordGotoSplitZoomIfNeeded() {
|
||||
guard isGotoSplitUITestRecordingEnabled() else { return }
|
||||
recordGotoSplitZoomRetry(attempt: 0)
|
||||
}
|
||||
|
||||
private func recordGotoSplitZoomRetry(attempt: Int) {
|
||||
let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5]
|
||||
let delay = attempt < delays.count ? delays[attempt] : delays.last!
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self,
|
||||
let workspace = self.tabManager?.selectedWorkspace else { return }
|
||||
|
||||
let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first
|
||||
let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first
|
||||
let browserSnapshot = browserPanel.flatMap {
|
||||
BrowserWindowPortalRegistry.debugSnapshot(for: $0.webView)
|
||||
}
|
||||
|
||||
var updates = self.gotoSplitFindStateSnapshot(for: workspace)
|
||||
updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false"
|
||||
updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? ""
|
||||
updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? ""
|
||||
updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? ""
|
||||
updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? ""
|
||||
updates["browserFrameAfterToggle"] = browserSnapshot.map {
|
||||
String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
$0.frameInWindow.origin.x,
|
||||
$0.frameInWindow.origin.y,
|
||||
$0.frameInWindow.size.width,
|
||||
$0.frameInWindow.size.height
|
||||
)
|
||||
} ?? ""
|
||||
updates["otherTerminalPanelIdAfterToggle"] = otherTerminal?.id.uuidString ?? ""
|
||||
updates["otherTerminalHostHiddenAfterToggle"] = otherTerminal.map { $0.hostedView.isHidden ? "true" : "false" } ?? ""
|
||||
updates["otherTerminalVisibleFlagAfterToggle"] = otherTerminal.map { $0.hostedView.debugPortalVisibleInUI ? "true" : "false" } ?? ""
|
||||
updates["otherTerminalFrameAfterToggle"] = otherTerminal.map {
|
||||
let frame = $0.hostedView.debugPortalFrameInWindow
|
||||
return String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
frame.origin.x,
|
||||
frame.origin.y,
|
||||
frame.size.width,
|
||||
frame.size.height
|
||||
)
|
||||
} ?? ""
|
||||
|
||||
let settled: Bool = {
|
||||
if workspace.bonsplitController.isSplitZoomed {
|
||||
if let focusedPanelId = workspace.focusedPanelId,
|
||||
workspace.terminalPanel(for: focusedPanelId) != nil {
|
||||
guard let browserSnapshot else { return false }
|
||||
return browserSnapshot.containerHidden && !browserSnapshot.visibleInUI
|
||||
}
|
||||
guard let otherTerminal else { return true }
|
||||
return otherTerminal.hostedView.isHidden && !otherTerminal.hostedView.debugPortalVisibleInUI
|
||||
}
|
||||
let browserRestored = browserSnapshot.map { !$0.containerHidden && $0.visibleInUI } ?? true
|
||||
let terminalRestored = otherTerminal.map {
|
||||
!$0.hostedView.isHidden && $0.hostedView.debugPortalVisibleInUI
|
||||
} ?? true
|
||||
return browserRestored && terminalRestored
|
||||
}()
|
||||
|
||||
if !settled && attempt < delays.count - 1 {
|
||||
self.recordGotoSplitZoomRetry(attempt: attempt + 1)
|
||||
return
|
||||
}
|
||||
|
||||
self.writeGotoSplitTestData(updates)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeGotoSplitTestData(_ updates: [String: String]) {
|
||||
guard let path = gotoSplitUITestDataPath() else { return }
|
||||
var payload = loadGotoSplitTestData(at: path)
|
||||
|
|
@ -7432,6 +7656,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) {
|
||||
_ = tabManager?.toggleFocusedSplitZoom()
|
||||
#if DEBUG
|
||||
recordGotoSplitZoomIfNeeded()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -10138,7 +10365,14 @@ private extension NSWindow {
|
|||
}
|
||||
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
|
||||
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
|
||||
return portalWebView
|
||||
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
|
||||
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
|
||||
// descendant of it. Treating every view in that slot as "web-owned"
|
||||
// blocks legitimate first-responder changes to overlay text fields.
|
||||
if view === portalWebView || view.isDescendant(of: portalWebView) {
|
||||
return portalWebView
|
||||
}
|
||||
return nil
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
|
|
|
|||
705
Sources/AppleScriptSupport.swift
Normal file
705
Sources/AppleScriptSupport.swift
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
import AppKit
|
||||
|
||||
private enum AppleScriptStrings {
|
||||
static let disabled = String(
|
||||
localized: "applescript.error.disabled",
|
||||
defaultValue: "AppleScript is disabled by the macos-applescript configuration."
|
||||
)
|
||||
static let missingAction = String(
|
||||
localized: "applescript.error.missingAction",
|
||||
defaultValue: "Missing action string."
|
||||
)
|
||||
static let missingInputText = String(
|
||||
localized: "applescript.error.missingInputText",
|
||||
defaultValue: "Missing input text."
|
||||
)
|
||||
static let missingTerminalTarget = String(
|
||||
localized: "applescript.error.missingTerminalTarget",
|
||||
defaultValue: "Missing terminal target."
|
||||
)
|
||||
static let missingSplitDirection = String(
|
||||
localized: "applescript.error.missingSplitDirection",
|
||||
defaultValue: "Missing or unknown split direction."
|
||||
)
|
||||
static let windowUnavailable = String(
|
||||
localized: "applescript.error.windowUnavailable",
|
||||
defaultValue: "Window is no longer available."
|
||||
)
|
||||
static let workspaceUnavailable = String(
|
||||
localized: "applescript.error.workspaceUnavailable",
|
||||
defaultValue: "Workspace is no longer available."
|
||||
)
|
||||
static let terminalUnavailable = String(
|
||||
localized: "applescript.error.terminalUnavailable",
|
||||
defaultValue: "Terminal is no longer available."
|
||||
)
|
||||
static let failedToCreateWindow = String(
|
||||
localized: "applescript.error.failedToCreateWindow",
|
||||
defaultValue: "Failed to create window."
|
||||
)
|
||||
static let failedToCreateWorkspace = String(
|
||||
localized: "applescript.error.failedToCreateWorkspace",
|
||||
defaultValue: "Failed to create workspace."
|
||||
)
|
||||
static let failedToCreateSplit = String(
|
||||
localized: "applescript.error.failedToCreateSplit",
|
||||
defaultValue: "Failed to create split."
|
||||
)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var fourCharCode: UInt32 {
|
||||
utf8.reduce(0) { ($0 << 8) + UInt32($1) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension Workspace {
|
||||
func scriptingTerminalPanels() -> [TerminalPanel] {
|
||||
var results: [TerminalPanel] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
for panelId in sidebarOrderedPanelIds() {
|
||||
guard seen.insert(panelId).inserted,
|
||||
let terminal = terminalPanel(for: panelId) else {
|
||||
continue
|
||||
}
|
||||
results.append(terminal)
|
||||
}
|
||||
|
||||
let remaining = panels.values
|
||||
.compactMap { $0 as? TerminalPanel }
|
||||
.sorted { $0.id.uuidString < $1.id.uuidString }
|
||||
|
||||
for terminal in remaining where seen.insert(terminal.id).inserted {
|
||||
results.append(terminal)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension NSApplication {
|
||||
var isAppleScriptEnabled: Bool {
|
||||
GhosttyApp.shared.appleScriptAutomationEnabled()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func validateScript(command: NSScriptCommand) -> Bool {
|
||||
guard isAppleScriptEnabled else {
|
||||
command.scriptErrorNumber = errAEEventNotPermitted
|
||||
command.scriptErrorString = AppleScriptStrings.disabled
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@objc(scriptWindows)
|
||||
var scriptWindows: [ScriptWindow] {
|
||||
guard isAppleScriptEnabled,
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return []
|
||||
}
|
||||
return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) }
|
||||
}
|
||||
|
||||
@objc(frontWindow)
|
||||
var frontWindow: ScriptWindow? {
|
||||
scriptWindows.first
|
||||
}
|
||||
|
||||
@objc(valueInScriptWindowsWithUniqueID:)
|
||||
func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
|
||||
guard isAppleScriptEnabled,
|
||||
let windowId = UUID(uuidString: uniqueID),
|
||||
let appDelegate = AppDelegate.shared,
|
||||
appDelegate.scriptableMainWindow(windowId: windowId) != nil else {
|
||||
return nil
|
||||
}
|
||||
return ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard isAppleScriptEnabled,
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return []
|
||||
}
|
||||
|
||||
return appDelegate.scriptableMainWindows()
|
||||
.flatMap { state in
|
||||
state.tabManager.tabs.flatMap { workspace in
|
||||
workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard isAppleScriptEnabled,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for state in appDelegate.scriptableMainWindows() {
|
||||
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
|
||||
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handlePerformActionScriptCommand:)
|
||||
func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let action = command.directParameter as? String else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingAction
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingTerminalTarget
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSNumber(value: terminal.perform(action: action))
|
||||
}
|
||||
|
||||
@objc(handleNewWindowScriptCommand:)
|
||||
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWindow
|
||||
return nil
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
return ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(handleNewTabScriptCommand:)
|
||||
func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
|
||||
return nil
|
||||
}
|
||||
|
||||
if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow {
|
||||
guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId)
|
||||
}
|
||||
|
||||
if let frontWindow = scriptWindows.first,
|
||||
let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) {
|
||||
return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId)
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
return ScriptWindow(windowId: windowId).selectedTab
|
||||
}
|
||||
|
||||
@objc(handleQuitScriptCommand:)
|
||||
func handleQuitScriptCommand(_ command: NSScriptCommand) {
|
||||
guard validateScript(command: command) else { return }
|
||||
terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptWindow)
|
||||
final class ScriptWindow: NSObject {
|
||||
let windowId: UUID
|
||||
|
||||
init(windowId: UUID) {
|
||||
self.windowId = windowId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var idValue: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return windowId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !windowTitle.isEmpty {
|
||||
return windowTitle
|
||||
}
|
||||
|
||||
return state.tabManager.selectedWorkspace?.title ?? ""
|
||||
}
|
||||
|
||||
@objc(tabs)
|
||||
var tabs: [ScriptTab] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return []
|
||||
}
|
||||
return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) }
|
||||
}
|
||||
|
||||
@objc(selectedTab)
|
||||
var selectedTab: ScriptTab? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let selectedId = state?.tabManager.selectedTabId else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: windowId, tabId: selectedId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return []
|
||||
}
|
||||
return state.tabManager.tabs.flatMap { workspace in
|
||||
workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTabsWithUniqueID:)
|
||||
func valueInTabs(uniqueID: String) -> ScriptTab? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let tabId = UUID(uuidString: uniqueID),
|
||||
let state,
|
||||
state.tabManager.tabs.contains(where: { $0.id == tabId }) else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: windowId, tabId: tabId)
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
let state else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
|
||||
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleActivateWindowCommand:)
|
||||
func handleActivateWindow(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseWindowCommand:)
|
||||
func handleCloseWindow(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let window = state?.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: appClassDescription,
|
||||
containerSpecifier: nil,
|
||||
key: "scriptWindows",
|
||||
uniqueID: windowId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptTab)
|
||||
final class ScriptTab: NSObject {
|
||||
let windowId: UUID
|
||||
let tabId: UUID
|
||||
|
||||
init(windowId: UUID, tabId: UUID) {
|
||||
self.windowId = windowId
|
||||
self.tabId = tabId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
private var workspace: Workspace? {
|
||||
state?.tabManager.tabs.first(where: { $0.id == tabId })
|
||||
}
|
||||
|
||||
private var window: ScriptWindow {
|
||||
ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var idValue: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return tabId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return workspace?.title ?? ""
|
||||
}
|
||||
|
||||
@objc(index)
|
||||
var index: Int {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state,
|
||||
let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
|
||||
return 0
|
||||
}
|
||||
return idx + 1
|
||||
}
|
||||
|
||||
@objc(selected)
|
||||
var selected: Bool {
|
||||
guard NSApp.isAppleScriptEnabled else { return false }
|
||||
return state?.tabManager.selectedTabId == tabId
|
||||
}
|
||||
|
||||
@objc(focusedTerminal)
|
||||
var focusedTerminal: ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminalId = workspace?.focusedTerminalPanel?.id else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let workspace else {
|
||||
return []
|
||||
}
|
||||
return workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: tabId, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let workspace,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
workspace.terminalPanel(for: terminalId) != nil else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
|
||||
}
|
||||
|
||||
@objc(handleSelectTabCommand:)
|
||||
func handleSelectTab(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
state.tabManager.selectWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseTabCommand:)
|
||||
func handleCloseTab(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if state.tabManager.tabs.count > 1 {
|
||||
state.tabManager.closeWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let windowClassDescription = window.classDescription as? NSScriptClassDescription,
|
||||
let windowSpecifier = window.objectSpecifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: windowClassDescription,
|
||||
containerSpecifier: windowSpecifier,
|
||||
key: "tabs",
|
||||
uniqueID: tabId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptTerminal)
|
||||
final class ScriptTerminal: NSObject {
|
||||
let workspaceId: UUID
|
||||
let terminalId: UUID
|
||||
|
||||
init(workspaceId: UUID, terminalId: UUID) {
|
||||
self.workspaceId = workspaceId
|
||||
self.terminalId = terminalId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindowForTab(workspaceId)
|
||||
}
|
||||
|
||||
private var workspace: Workspace? {
|
||||
state?.tabManager.tabs.first(where: { $0.id == workspaceId })
|
||||
}
|
||||
|
||||
private var terminal: TerminalPanel? {
|
||||
workspace?.terminalPanel(for: terminalId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var stableID: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminalId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminal?.displayTitle ?? ""
|
||||
}
|
||||
|
||||
@objc(workingDirectory)
|
||||
var workingDirectory: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminal?.directory ?? ""
|
||||
}
|
||||
|
||||
func input(text: String) -> Bool {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminal else {
|
||||
return false
|
||||
}
|
||||
terminal.sendText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
func perform(action: String) -> Bool {
|
||||
guard NSApp.isAppleScriptEnabled else { return false }
|
||||
return terminal?.performBindingAction(action) ?? false
|
||||
}
|
||||
|
||||
@objc(handleSplitCommand:)
|
||||
func handleSplit(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32,
|
||||
let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingSplitDirection
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction),
|
||||
workspace.terminalPanel(for: newPanelId) != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateSplit
|
||||
return nil
|
||||
}
|
||||
|
||||
return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId)
|
||||
}
|
||||
|
||||
@objc(handleFocusCommand:)
|
||||
func handleFocus(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if let app = AppDelegate.shared {
|
||||
_ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true)
|
||||
}
|
||||
state.tabManager.selectWorkspace(workspace)
|
||||
workspace.focusPanel(terminalId)
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseCommand:)
|
||||
func handleClose(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if workspace.panels.count == 1 {
|
||||
if state.tabManager.tabs.count > 1 {
|
||||
state.tabManager.closeWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard workspace.closePanel(terminalId, force: true) else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: appClassDescription,
|
||||
containerSpecifier: nil,
|
||||
key: "terminals",
|
||||
uniqueID: terminalId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptInputTextCommand)
|
||||
final class ScriptInputTextCommand: NSScriptCommand {
|
||||
override func performDefaultImplementation() -> Any? {
|
||||
guard NSApp.validateScript(command: self) else { return nil }
|
||||
|
||||
guard let text = directParameter as? String else {
|
||||
scriptErrorNumber = errAEParamMissed
|
||||
scriptErrorString = AppleScriptStrings.missingInputText
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
|
||||
scriptErrorNumber = errAEParamMissed
|
||||
scriptErrorString = AppleScriptStrings.missingTerminalTarget
|
||||
return nil
|
||||
}
|
||||
|
||||
guard terminal.input(text: text) else {
|
||||
scriptErrorNumber = errAEEventFailed
|
||||
scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScriptSplitDirection {
|
||||
case right
|
||||
case left
|
||||
case down
|
||||
case up
|
||||
|
||||
init?(code: UInt32) {
|
||||
switch code {
|
||||
case "GSrt".fourCharCode: self = .right
|
||||
case "GSlf".fourCharCode: self = .left
|
||||
case "GSdn".fourCharCode: self = .down
|
||||
case "GSup".fourCharCode: self = .up
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var splitDirection: SplitDirection {
|
||||
switch self {
|
||||
case .right: return .right
|
||||
case .left: return .left
|
||||
case .down: return .down
|
||||
case .up: return .up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import WebKit
|
|||
|
||||
private var cmuxWindowBrowserPortalKey: UInt8 = 0
|
||||
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
|
||||
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
|
||||
|
||||
#if DEBUG
|
||||
private func browserPortalDebugToken(_ view: NSView?) -> String {
|
||||
|
|
@ -31,6 +32,17 @@ private extension NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
private extension NSResponder {
|
||||
var browserPortalOwningView: NSView? {
|
||||
if let editor = self as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
return editedView
|
||||
}
|
||||
return self as? NSView
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
func browserPortalNotifyHidden(reason: String) {
|
||||
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
|
||||
|
|
@ -81,6 +93,115 @@ private extension WKWebView {
|
|||
}
|
||||
}
|
||||
|
||||
enum HostedInspectorDockSide {
|
||||
case leading
|
||||
case trailing
|
||||
|
||||
static func resolve(
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
epsilon: CGFloat = 1
|
||||
) -> Self? {
|
||||
if pageFrame.maxX <= inspectorFrame.minX + epsilon {
|
||||
return .trailing
|
||||
}
|
||||
if inspectorFrame.maxX <= pageFrame.minX + epsilon {
|
||||
return .leading
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dividerX(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
return inspectorFrame.maxX
|
||||
case .trailing:
|
||||
return inspectorFrame.minX
|
||||
}
|
||||
}
|
||||
|
||||
func dividerHitRect(
|
||||
in bounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
expansion: CGFloat
|
||||
) -> NSRect {
|
||||
let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
|
||||
let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
|
||||
return NSRect(
|
||||
x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion,
|
||||
y: minY,
|
||||
width: expansion * 2,
|
||||
height: max(0, maxY - minY)
|
||||
)
|
||||
}
|
||||
|
||||
func clampedDividerX(
|
||||
_ proposedDividerX: CGFloat,
|
||||
containerBounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
minimumInspectorWidth: CGFloat
|
||||
) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
let minDividerX = min(containerBounds.maxX, containerBounds.minX + minimumInspectorWidth)
|
||||
let maxDividerX = max(minDividerX, min(containerBounds.maxX, pageFrame.maxX))
|
||||
return max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
case .trailing:
|
||||
let minDividerX = max(containerBounds.minX, pageFrame.minX)
|
||||
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
|
||||
return max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
}
|
||||
}
|
||||
|
||||
func inspectorWidth(forDividerX dividerX: CGFloat, in containerBounds: NSRect) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
return max(0, dividerX - containerBounds.minX)
|
||||
case .trailing:
|
||||
return max(0, containerBounds.maxX - dividerX)
|
||||
}
|
||||
}
|
||||
|
||||
func resizedFrames(
|
||||
preferredWidth: CGFloat,
|
||||
in containerBounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
minimumInspectorWidth _: CGFloat
|
||||
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
|
||||
switch self {
|
||||
case .leading:
|
||||
let maximumInspectorWidth = max(0, containerBounds.width)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth)
|
||||
|
||||
var nextPageFrame = pageFrame
|
||||
nextPageFrame.origin.x = dividerX
|
||||
nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
|
||||
var nextInspectorFrame = inspectorFrame
|
||||
nextInspectorFrame.origin.x = containerBounds.minX
|
||||
nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX)
|
||||
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
|
||||
|
||||
case .trailing:
|
||||
let maximumInspectorWidth = max(0, containerBounds.width)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth)
|
||||
|
||||
var nextPageFrame = pageFrame
|
||||
nextPageFrame.origin.x = containerBounds.minX
|
||||
nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
|
||||
|
||||
var nextInspectorFrame = inspectorFrame
|
||||
nextInspectorFrame.origin.x = dividerX
|
||||
nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowBrowserHostView: NSView {
|
||||
private struct DividerRegion {
|
||||
let rectInWindow: NSRect
|
||||
|
|
@ -97,6 +218,7 @@ final class WindowBrowserHostView: NSView {
|
|||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
let dockSide: HostedInspectorDockSide
|
||||
}
|
||||
|
||||
private struct HostedInspectorDividerDragState {
|
||||
|
|
@ -104,6 +226,7 @@ final class WindowBrowserHostView: NSView {
|
|||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
let dockSide: HostedInspectorDockSide
|
||||
let initialWindowX: CGFloat
|
||||
let initialPageFrame: NSRect
|
||||
let initialInspectorFrame: NSRect
|
||||
|
|
@ -131,6 +254,7 @@ final class WindowBrowserHostView: NSView {
|
|||
private var trackingArea: NSTrackingArea?
|
||||
private var activeDividerCursorKind: DividerCursorKind?
|
||||
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
|
||||
private var lastHostedInspectorLayoutBoundsSize: NSSize?
|
||||
|
||||
deinit {
|
||||
if let trackingArea {
|
||||
|
|
@ -200,6 +324,11 @@ final class WindowBrowserHostView: NSView {
|
|||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
if let previousSize = lastHostedInspectorLayoutBoundsSize,
|
||||
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
|
||||
return
|
||||
}
|
||||
lastHostedInspectorLayoutBoundsSize = bounds.size
|
||||
reapplyHostedInspectorDividersIfNeeded(reason: "host.layout")
|
||||
}
|
||||
|
||||
|
|
@ -378,11 +507,13 @@ final class WindowBrowserHostView: NSView {
|
|||
return
|
||||
}
|
||||
|
||||
hostedInspectorHit.slotView.isHostedInspectorDividerDragActive = true
|
||||
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
|
||||
slotView: hostedInspectorHit.slotView,
|
||||
containerView: hostedInspectorHit.containerView,
|
||||
pageView: hostedInspectorHit.pageView,
|
||||
inspectorView: hostedInspectorHit.inspectorView,
|
||||
dockSide: hostedInspectorHit.dockSide,
|
||||
initialWindowX: event.locationInWindow.x,
|
||||
initialPageFrame: hostedInspectorHit.pageView.frame,
|
||||
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
|
||||
|
|
@ -404,6 +535,7 @@ final class WindowBrowserHostView: NSView {
|
|||
return
|
||||
}
|
||||
guard dragState.slotView.window === window else {
|
||||
dragState.slotView.isHostedInspectorDividerDragActive = false
|
||||
hostedInspectorDividerDrag = nil
|
||||
super.mouseDragged(with: event)
|
||||
return
|
||||
|
|
@ -414,20 +546,31 @@ final class WindowBrowserHostView: NSView {
|
|||
Self.minimumHostedInspectorWidth,
|
||||
max(60, dragState.initialInspectorFrame.width)
|
||||
)
|
||||
let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX)
|
||||
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
|
||||
let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX)
|
||||
let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX)
|
||||
let initialDividerX = dragState.dockSide.dividerX(
|
||||
pageFrame: dragState.initialPageFrame,
|
||||
inspectorFrame: dragState.initialInspectorFrame
|
||||
)
|
||||
let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX)
|
||||
let clampedDividerX = dragState.dockSide.clampedDividerX(
|
||||
proposedDividerX,
|
||||
containerBounds: containerBounds,
|
||||
pageFrame: dragState.initialPageFrame,
|
||||
minimumInspectorWidth: minimumInspectorWidth
|
||||
)
|
||||
let inspectorWidth = dragState.dockSide.inspectorWidth(
|
||||
forDividerX: clampedDividerX,
|
||||
in: containerBounds
|
||||
)
|
||||
|
||||
dragState.slotView.preferredHostedInspectorWidth = inspectorWidth
|
||||
dragState.slotView.recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
|
||||
let appliedFrames = applyHostedInspectorDividerWidth(
|
||||
inspectorWidth,
|
||||
to: HostedInspectorDividerHit(
|
||||
slotView: dragState.slotView,
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
inspectorView: dragState.inspectorView,
|
||||
dockSide: dragState.dockSide
|
||||
),
|
||||
reason: "drag"
|
||||
)
|
||||
|
|
@ -438,7 +581,8 @@ final class WindowBrowserHostView: NSView {
|
|||
slotView: dragState.slotView,
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
inspectorView: dragState.inspectorView,
|
||||
dockSide: dragState.dockSide
|
||||
)
|
||||
)
|
||||
#if DEBUG
|
||||
|
|
@ -453,6 +597,7 @@ final class WindowBrowserHostView: NSView {
|
|||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if let dragState = hostedInspectorDividerDrag {
|
||||
dragState.slotView.isHostedInspectorDividerDragActive = false
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " +
|
||||
|
|
@ -710,22 +855,31 @@ final class WindowBrowserHostView: NSView {
|
|||
while let inspectorView = current, inspectorView !== slot {
|
||||
guard let containerView = inspectorView.superview else { break }
|
||||
|
||||
let pageCandidates = containerView.subviews.filter { candidate in
|
||||
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false }
|
||||
guard candidate !== inspectorView else { return false }
|
||||
guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false }
|
||||
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
|
||||
let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in
|
||||
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil }
|
||||
guard candidate !== inspectorView else { return nil }
|
||||
guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else {
|
||||
return nil
|
||||
}
|
||||
guard let dockSide = HostedInspectorDockSide.resolve(
|
||||
pageFrame: candidate.frame,
|
||||
inspectorFrame: inspectorView.frame
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return (view: candidate, dockSide: dockSide)
|
||||
}
|
||||
|
||||
if let pageView = pageCandidates.max(by: {
|
||||
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
|
||||
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
|
||||
if let pageCandidate = pageCandidates.max(by: {
|
||||
hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView)
|
||||
< hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView)
|
||||
}) {
|
||||
bestHit = HostedInspectorDividerHit(
|
||||
slotView: slot,
|
||||
containerView: containerView,
|
||||
pageView: pageView,
|
||||
inspectorView: inspectorView
|
||||
pageView: pageCandidate.view,
|
||||
inspectorView: inspectorView,
|
||||
dockSide: pageCandidate.dockSide
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -739,13 +893,11 @@ final class WindowBrowserHostView: NSView {
|
|||
let slotBounds = hit.slotView.bounds
|
||||
let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView)
|
||||
let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView)
|
||||
let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY))
|
||||
let maxY = min(slotBounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
|
||||
return NSRect(
|
||||
x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion,
|
||||
y: minY,
|
||||
width: Self.hostedInspectorDividerHitExpansion * 2,
|
||||
height: max(0, maxY - minY)
|
||||
return hit.dockSide.dividerHitRect(
|
||||
in: slotBounds,
|
||||
pageFrame: pageFrame,
|
||||
inspectorFrame: inspectorFrame,
|
||||
expansion: Self.hostedInspectorDividerHitExpansion
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -779,10 +931,24 @@ final class WindowBrowserHostView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) {
|
||||
guard let preferredWidth = slot.preferredHostedInspectorWidth else { return }
|
||||
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return }
|
||||
@discardableResult
|
||||
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) -> Bool {
|
||||
guard !slot.isHostedInspectorDividerDragActive else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=skipReapply slot=\(browserPortalDebugToken(slot)) " +
|
||||
"reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let preferredWidth = slot.resolvedPreferredHostedInspectorWidth(in: slot.bounds) else { return false }
|
||||
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false }
|
||||
let oldPageFrame = hit.pageView.frame
|
||||
let oldInspectorFrame = hit.inspectorView.frame
|
||||
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
|
||||
return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) ||
|
||||
!Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -792,19 +958,20 @@ final class WindowBrowserHostView: NSView {
|
|||
reason: String
|
||||
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
|
||||
let containerBounds = hit.containerView.bounds
|
||||
let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth)
|
||||
let nextFrames = hit.dockSide.resizedFrames(
|
||||
preferredWidth: preferredWidth,
|
||||
in: containerBounds,
|
||||
pageFrame: hit.pageView.frame,
|
||||
inspectorFrame: hit.inspectorView.frame,
|
||||
minimumInspectorWidth: 0
|
||||
)
|
||||
let pageFrame = nextFrames.pageFrame
|
||||
let inspectorFrame = nextFrames.inspectorFrame
|
||||
|
||||
var pageFrame = hit.pageView.frame
|
||||
pageFrame.size.width = max(0, dividerX - pageFrame.minX)
|
||||
|
||||
var inspectorFrame = hit.inspectorView.frame
|
||||
inspectorFrame.origin.x = dividerX
|
||||
inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
|
||||
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
|
||||
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
|
||||
let oldPageFrame = hit.pageView.frame
|
||||
let oldInspectorFrame = hit.inspectorView.frame
|
||||
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5)
|
||||
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5)
|
||||
guard pageChanged || inspectorChanged else {
|
||||
return (pageFrame, inspectorFrame)
|
||||
}
|
||||
|
|
@ -817,15 +984,23 @@ final class WindowBrowserHostView: NSView {
|
|||
CATransaction.commit()
|
||||
hit.slotView.isApplyingHostedInspectorLayout = false
|
||||
|
||||
hit.pageView.needsLayout = true
|
||||
hit.inspectorView.needsLayout = true
|
||||
hit.containerView.needsLayout = true
|
||||
hit.slotView.needsLayout = true
|
||||
let isLiveDrag = reason == "drag"
|
||||
hit.pageView.needsDisplay = true
|
||||
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
|
||||
hit.inspectorView.needsDisplay = true
|
||||
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
|
||||
hit.containerView.needsDisplay = true
|
||||
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
|
||||
hit.slotView.needsDisplay = true
|
||||
hit.slotView.setNeedsDisplay(hit.slotView.bounds)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " +
|
||||
"container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " +
|
||||
"preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
|
||||
"liveDrag=\(isLiveDrag ? 1 : 0) " +
|
||||
"pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " +
|
||||
"oldPageFrame=\(browserPortalDebugFrame(oldPageFrame)) oldInspectorFrame=\(browserPortalDebugFrame(oldInspectorFrame)) " +
|
||||
"pageFrame=\(browserPortalDebugFrame(pageFrame)) " +
|
||||
"inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))"
|
||||
)
|
||||
|
|
@ -903,6 +1078,11 @@ final class WindowBrowserHostView: NSView {
|
|||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.width - rhs.width) <= epsilon &&
|
||||
abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func visibleDescendants(in root: NSView) -> [NSView] {
|
||||
var descendants: [NSView] = []
|
||||
var stack = Array(root.subviews.reversed())
|
||||
|
|
@ -978,9 +1158,12 @@ private final class BrowserDropZoneOverlayView: NSView {
|
|||
struct BrowserPortalSearchOverlayConfiguration {
|
||||
let panelId: UUID
|
||||
let searchState: BrowserSearchState
|
||||
let focusRequestGeneration: UInt64
|
||||
let canApplyFocusRequest: (UInt64) -> Bool
|
||||
let onNext: () -> Void
|
||||
let onPrevious: () -> Void
|
||||
let onClose: () -> Void
|
||||
let onFieldDidFocus: () -> Void
|
||||
}
|
||||
|
||||
struct BrowserPaneDropContext: Equatable {
|
||||
|
|
@ -1359,8 +1542,11 @@ final class WindowBrowserSlotView: NSView {
|
|||
private var isRefreshingInteractionLayers = false
|
||||
private var paneTopChromeHeight: CGFloat = 0
|
||||
var preferredHostedInspectorWidth: CGFloat?
|
||||
private var preferredHostedInspectorWidthFraction: CGFloat?
|
||||
fileprivate var isHostedInspectorDividerDragActive = false
|
||||
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
|
||||
fileprivate var isApplyingHostedInspectorLayout = false
|
||||
private var lastHostedInspectorLayoutBoundsSize: NSSize?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
|
@ -1390,6 +1576,11 @@ final class WindowBrowserSlotView: NSView {
|
|||
paneDropTargetView.frame = bounds
|
||||
applyResolvedDropZoneOverlay()
|
||||
guard !isApplyingHostedInspectorLayout else { return }
|
||||
if let previousSize = lastHostedInspectorLayoutBoundsSize,
|
||||
Self.sizeApproximatelyEqual(previousSize, bounds.size) {
|
||||
return
|
||||
}
|
||||
lastHostedInspectorLayoutBoundsSize = bounds.size
|
||||
onHostedInspectorLayout?(self)
|
||||
}
|
||||
|
||||
|
|
@ -1399,6 +1590,27 @@ final class WindowBrowserSlotView: NSView {
|
|||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) {
|
||||
preferredHostedInspectorWidth = width
|
||||
guard containerBounds.width > 0 else {
|
||||
preferredHostedInspectorWidthFraction = nil
|
||||
return
|
||||
}
|
||||
preferredHostedInspectorWidthFraction = width / containerBounds.width
|
||||
}
|
||||
|
||||
func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? {
|
||||
if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 {
|
||||
return max(0, containerBounds.width * preferredHostedInspectorWidthFraction)
|
||||
}
|
||||
return preferredHostedInspectorWidth
|
||||
}
|
||||
|
||||
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.width - rhs.width) <= epsilon &&
|
||||
abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
func setDropZoneOverlay(zone: DropZone?) {
|
||||
forwardedDropZone = zone
|
||||
applyResolvedDropZoneOverlay()
|
||||
|
|
@ -1420,23 +1632,63 @@ final class WindowBrowserSlotView: NSView {
|
|||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
private func logSearchOverlayEvent(_ action: String, panelId: UUID?) {
|
||||
#if DEBUG
|
||||
let firstResponderSummary: String = {
|
||||
guard let firstResponder = window?.firstResponder else { return "nil" }
|
||||
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
|
||||
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "fieldEditor(delegate=\(delegateSummary))"
|
||||
}
|
||||
return String(describing: type(of: firstResponder))
|
||||
}()
|
||||
dlog(
|
||||
"browser.findbar.portal action=\(action) " +
|
||||
"panel=\(panelId?.uuidString.prefix(5) ?? "nil") " +
|
||||
"window=\(window?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(firstResponderSummary) " +
|
||||
"hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
|
||||
guard let configuration else {
|
||||
logSearchOverlayEvent("remove", panelId: nil)
|
||||
if let overlay = searchOverlayHostingView {
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
nil,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
searchOverlayHostingView?.removeFromSuperview()
|
||||
searchOverlayHostingView = nil
|
||||
return
|
||||
}
|
||||
|
||||
logSearchOverlayEvent("set", panelId: configuration.panelId)
|
||||
let rootView = BrowserSearchOverlay(
|
||||
panelId: configuration.panelId,
|
||||
searchState: configuration.searchState,
|
||||
focusRequestGeneration: configuration.focusRequestGeneration,
|
||||
canApplyFocusRequest: configuration.canApplyFocusRequest,
|
||||
onNext: configuration.onNext,
|
||||
onPrevious: configuration.onPrevious,
|
||||
onClose: configuration.onClose
|
||||
onClose: configuration.onClose,
|
||||
onFieldDidFocus: configuration.onFieldDidFocus
|
||||
)
|
||||
|
||||
if let overlay = searchOverlayHostingView {
|
||||
logSearchOverlayEvent("updateExisting", panelId: configuration.panelId)
|
||||
overlay.rootView = rootView
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
configuration.panelId,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
if overlay.superview !== self {
|
||||
overlay.removeFromSuperview()
|
||||
addSubview(overlay)
|
||||
|
|
@ -1452,6 +1704,12 @@ final class WindowBrowserSlotView: NSView {
|
|||
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
configuration.panelId,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
|
|
@ -1460,36 +1718,79 @@ final class WindowBrowserSlotView: NSView {
|
|||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
searchOverlayHostingView = overlay
|
||||
logSearchOverlayEvent("create", panelId: configuration.panelId)
|
||||
}
|
||||
|
||||
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
|
||||
guard let overlay = searchOverlayHostingView,
|
||||
let view = responder.browserPortalOwningView,
|
||||
view.isDescendant(of: overlay) else {
|
||||
return nil
|
||||
}
|
||||
return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
|
||||
guard let firstResponder = window.firstResponder,
|
||||
searchOverlayPanelId(for: firstResponder) == panelId else {
|
||||
return false
|
||||
}
|
||||
return window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
func pinHostedWebView(_ webView: WKWebView) {
|
||||
guard webView.superview === self else { return }
|
||||
|
||||
let needsNewConstraints =
|
||||
let needsPlainWebViewFrameReset =
|
||||
!Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
|
||||
Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
|
||||
let needsFrameHosting =
|
||||
hostedWebView !== webView ||
|
||||
hostedWebViewConstraints.isEmpty ||
|
||||
webView.translatesAutoresizingMaskIntoConstraints
|
||||
guard needsNewConstraints else {
|
||||
!hostedWebViewConstraints.isEmpty ||
|
||||
needsPlainWebViewFrameReset ||
|
||||
!webView.translatesAutoresizingMaskIntoConstraints ||
|
||||
webView.autoresizingMask != [.width, .height]
|
||||
guard needsFrameHosting else {
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
|
||||
hostedWebViewConstraints = []
|
||||
hostedWebView = webView
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.autoresizingMask = []
|
||||
hostedWebViewConstraints = [
|
||||
webView.topAnchor.constraint(equalTo: topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(hostedWebViewConstraints)
|
||||
// Attached Web Inspector mutates the moved WKWebView's frame directly.
|
||||
// Re-pin plain web views after cross-host reattach, but preserve the
|
||||
// WebKit-managed split frame when docked DevTools siblings are present.
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = bounds
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(frame.minX - bounds.minX) > epsilon ||
|
||||
abs(frame.minY - bounds.minY) > epsilon ||
|
||||
abs(frame.width - bounds.width) > epsilon ||
|
||||
abs(frame.height - bounds.height) > epsilon
|
||||
}
|
||||
|
||||
private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool {
|
||||
var stack = host.subviews.filter { $0 !== primaryWebView }
|
||||
while let current = stack.popLast() {
|
||||
if current.isDescendant(of: primaryWebView) {
|
||||
continue
|
||||
}
|
||||
if String(describing: type(of: current)).contains("WK") {
|
||||
return true
|
||||
}
|
||||
stack.append(contentsOf: current.subviews)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func effectivePaneTopChromeHeight() -> CGFloat {
|
||||
paneTopChromeHeight
|
||||
}
|
||||
|
|
@ -1687,6 +1988,18 @@ final class WindowBrowserPortal: NSObject {
|
|||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
static func shouldTreatSplitResizeAsExternalGeometry(
|
||||
_ splitView: NSSplitView,
|
||||
window: NSWindow,
|
||||
hostView: WindowBrowserHostView
|
||||
) -> Bool {
|
||||
guard splitView.window === window else { return false }
|
||||
// WebKit's attached DevTools uses internal NSSplitView instances for the
|
||||
// side/bottom inspector layout. Those resizes are local to hosted content
|
||||
// and should not trigger a full portal re-sync/refresh pass.
|
||||
return !splitView.isDescendant(of: hostView)
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
|
|
@ -1718,7 +2031,11 @@ final class WindowBrowserPortal: NSObject {
|
|||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
Self.shouldTreatSplitResizeAsExternalGeometry(
|
||||
splitView,
|
||||
window: window,
|
||||
hostView: self.hostView
|
||||
) else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
|
|
@ -1753,6 +2070,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
guard let webView = entry.webView,
|
||||
let containerView = entry.containerView,
|
||||
!containerView.isHidden else { continue }
|
||||
guard webView.superview === containerView else { continue }
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
|
|
@ -1872,7 +2190,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
case (nil, nil):
|
||||
return true
|
||||
case let (lhs?, rhs?):
|
||||
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
|
||||
return lhs.panelId == rhs.panelId &&
|
||||
lhs.searchState === rhs.searchState &&
|
||||
lhs.focusRequestGeneration == rhs.focusRequestGeneration
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -1966,6 +2286,15 @@ final class WindowBrowserPortal: NSObject {
|
|||
phase: String
|
||||
) {
|
||||
guard !containerView.isHidden else { return }
|
||||
guard !containerView.isHostedInspectorDividerDragActive else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) reason=\(reason) phase=\(phase) drag=1"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
containerView.needsLayout = true
|
||||
containerView.needsDisplay = true
|
||||
|
|
@ -2043,7 +2372,12 @@ final class WindowBrowserPortal: NSObject {
|
|||
// UI state does not get orphaned in the old host during split churn.
|
||||
let relatedSubviews = sourceSuperview.subviews.filter { view in
|
||||
if view === primaryWebView { return true }
|
||||
return String(describing: type(of: view)).contains("WK")
|
||||
let className = String(describing: type(of: view))
|
||||
guard className.contains("WK") else { return false }
|
||||
if className.contains("WKInspector") {
|
||||
return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard !relatedSubviews.isEmpty else { return }
|
||||
#if DEBUG
|
||||
|
|
@ -2144,6 +2478,26 @@ final class WindowBrowserPortal: NSObject {
|
|||
entry.containerView?.setSearchOverlay(configuration)
|
||||
}
|
||||
|
||||
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
|
||||
for entry in entriesByWebViewId.values {
|
||||
if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) {
|
||||
return panelId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool {
|
||||
guard let window else { return false }
|
||||
for entry in entriesByWebViewId.values {
|
||||
if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
let resolvedHeight = max(0, height)
|
||||
|
|
@ -2406,7 +2760,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
if !containerView.isHidden {
|
||||
if !containerView.isHidden, webView.superview === containerView {
|
||||
webView.browserPortalNotifyHidden(reason: reason)
|
||||
}
|
||||
containerView.isHidden = true
|
||||
|
|
@ -2508,7 +2862,18 @@ final class WindowBrowserPortal: NSObject {
|
|||
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||||
refreshReasons.append("syncAttachContainer")
|
||||
}
|
||||
if webView.superview !== containerView {
|
||||
let shouldPreserveExternalHostForHiddenEntry =
|
||||
!entry.visibleInUI &&
|
||||
webView.superview !== containerView
|
||||
if shouldPreserveExternalHostForHiddenEntry {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " +
|
||||
"container=\(browserPortalDebugToken(containerView))"
|
||||
)
|
||||
#endif
|
||||
} else if webView.superview !== containerView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
|
||||
|
|
@ -2699,15 +3064,16 @@ final class WindowBrowserPortal: NSObject {
|
|||
refreshReasons.append("bounds")
|
||||
}
|
||||
|
||||
let containerOwnsWebView = webView.superview === containerView
|
||||
let containerBounds = containerView.bounds
|
||||
let preNormalizeWebFrame = webView.frame
|
||||
let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero
|
||||
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
|
||||
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
|
||||
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
|
||||
#if DEBUG
|
||||
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
|
||||
#endif
|
||||
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
|
||||
if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
|
||||
let oldWebFrame = preNormalizeWebFrame
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
|
@ -2766,14 +3132,31 @@ final class WindowBrowserPortal: NSObject {
|
|||
if transientRecoveryReason == nil {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
if !shouldHide, !refreshReasons.isEmpty {
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
reason: "\(source):" + refreshReasons.joined(separator: ",")
|
||||
)
|
||||
let hostedInspectorAdjustedDuringSync =
|
||||
containerOwnsWebView &&
|
||||
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
|
||||
if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
|
||||
if hostedInspectorAdjustedDuringSync {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) reason=\(source):" +
|
||||
"\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
reason: "\(source):" + refreshReasons.joined(separator: ",")
|
||||
)
|
||||
}
|
||||
}
|
||||
if containerOwnsWebView, !hostedInspectorAdjustedDuringSync {
|
||||
// Keep the existing post-sync pass for cases where the inspector candidate
|
||||
// appears only after WebKit settles, but avoid a second apply when sync already clamped it.
|
||||
_ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh")
|
||||
}
|
||||
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
|
||||
|
|
@ -2783,6 +3166,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
|
||||
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
|
||||
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
|
||||
"containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " +
|
||||
"inspectorAdjusted=\(hostedInspectorAdjustedDuringSync ? 1 : 0) " +
|
||||
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
|
||||
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
|
||||
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
|
||||
|
|
@ -2856,6 +3241,19 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? {
|
||||
guard let entry = entriesByWebViewId[webViewId] else { return nil }
|
||||
let frameInWindow: CGRect = {
|
||||
guard let container = entry.containerView, container.window != nil else { return .zero }
|
||||
return container.convert(container.bounds, to: nil)
|
||||
}()
|
||||
return BrowserWindowPortalRegistry.DebugSnapshot(
|
||||
visibleInUI: entry.visibleInUI,
|
||||
containerHidden: entry.containerView?.isHidden ?? true,
|
||||
frameInWindow: frameInWindow
|
||||
)
|
||||
}
|
||||
|
||||
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
|
|
@ -2875,6 +3273,12 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
@MainActor
|
||||
enum BrowserWindowPortalRegistry {
|
||||
struct DebugSnapshot {
|
||||
let visibleInUI: Bool
|
||||
let containerHidden: Bool
|
||||
let frameInWindow: CGRect
|
||||
}
|
||||
|
||||
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
|
||||
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
|
|
@ -3012,6 +3416,19 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
|
||||
}
|
||||
|
||||
static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? {
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard let portal = portalsByWindowId[windowId] else { return nil }
|
||||
return portal.searchOverlayPanelId(for: responder)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard let portal = portalsByWindowId[windowId] else { return false }
|
||||
return portal.yieldSearchOverlayFocusIfOwned(by: panelId)
|
||||
}
|
||||
|
||||
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
|
|
@ -3038,6 +3455,13 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.forceRefreshWebView(withId: webViewId, reason: reason)
|
||||
}
|
||||
|
||||
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return nil }
|
||||
return portal.debugSnapshot(forWebViewId: webViewId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func debugPortalCount() -> Int {
|
||||
portalsByWindowId.count
|
||||
|
|
|
|||
|
|
@ -82,6 +82,40 @@ func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor {
|
|||
let clampedOpacity = max(0, min(opacity, 1))
|
||||
return NSColor.white.withAlphaComponent(clampedOpacity)
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
enum InternalTabDragConfigurationProvider {
|
||||
// These drags only make sense inside cmux. Outside the app, Finder should
|
||||
// reject them instead of materializing placeholder files from the payload.
|
||||
static let value = DragConfiguration(
|
||||
operationsWithinApp: .init(allowCopy: false, allowMove: true, allowDelete: false),
|
||||
operationsOutsideApp: .init(allowCopy: false, allowMove: false, allowDelete: false)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct InternalTabDragConfigurationModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
#if compiler(>=6.2)
|
||||
if #available(macOS 26.0, *) {
|
||||
content.dragConfiguration(InternalTabDragConfigurationProvider.value)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func internalOnlyTabDrag() -> some View {
|
||||
modifier(InternalTabDragConfigurationModifier())
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutHintPillBackground: View {
|
||||
var emphasis: Double = 1.0
|
||||
|
||||
|
|
@ -1398,15 +1432,10 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private enum CommandPaletteRestoreFocusIntent {
|
||||
case panel
|
||||
case browserAddressBar
|
||||
}
|
||||
|
||||
private struct CommandPaletteRestoreFocusTarget {
|
||||
let workspaceId: UUID
|
||||
let panelId: UUID
|
||||
let intent: CommandPaletteRestoreFocusIntent
|
||||
let intent: PanelFocusIntent
|
||||
}
|
||||
|
||||
private enum CommandPaletteInputFocusTarget {
|
||||
|
|
@ -5337,7 +5366,7 @@ struct ContentView: View {
|
|||
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: Bool,
|
||||
focusedBrowserAddressBarPanelId: UUID?,
|
||||
focusedPanelId: UUID
|
||||
focusedPanelId: UUID?
|
||||
) -> Bool {
|
||||
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
|
||||
}
|
||||
|
|
@ -5383,15 +5412,10 @@ struct ContentView: View {
|
|||
|
||||
private func presentCommandPalette(initialQuery: String) {
|
||||
if let panelContext = focusedPanelContext {
|
||||
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
|
||||
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
|
||||
focusedPanelId: panelContext.panelId
|
||||
)
|
||||
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: panelContext.workspace.id,
|
||||
panelId: panelContext.panelId,
|
||||
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
|
||||
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
|
||||
)
|
||||
} else {
|
||||
commandPaletteRestoreFocusTarget = nil
|
||||
|
|
@ -5468,7 +5492,7 @@ struct ContentView: View {
|
|||
if let clickedFocusTarget {
|
||||
dlog(
|
||||
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
|
||||
)
|
||||
} else {
|
||||
dlog("palette.dismiss.backdrop focusTarget=nil")
|
||||
|
|
@ -5507,10 +5531,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: window
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5522,10 +5547,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5563,16 +5589,35 @@ struct ContentView: View {
|
|||
continue
|
||||
}
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspace.id,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .browser(.webView),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func commandPaletteRestoreFocusTarget(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
fallbackIntent: PanelFocusIntent,
|
||||
in window: NSWindow?
|
||||
) -> CommandPaletteRestoreFocusTarget {
|
||||
let intent = tabManager.tabs
|
||||
.first(where: { $0.id == workspaceId })?
|
||||
.panels[panelId]?
|
||||
.captureFocusIntent(in: window) ?? fallbackIntent
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: intent
|
||||
)
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteFocus(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
|
|
@ -5588,8 +5633,9 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
|
|
@ -5598,33 +5644,32 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
) {
|
||||
guard !isCommandPalettePresented else { return }
|
||||
guard target.intent == .browserAddressBar else { return }
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
guard let appDelegate = AppDelegate.shared else { return }
|
||||
|
||||
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||||
restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: target,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
#if DEBUG
|
||||
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
|
||||
switch intent {
|
||||
case .panel:
|
||||
return "panel"
|
||||
case .terminal(.surface):
|
||||
return "terminal.surface"
|
||||
case .terminal(.findField):
|
||||
return "terminal.findField"
|
||||
case .browser(.webView):
|
||||
return "browser.webView"
|
||||
case .browser(.addressBar):
|
||||
return "browser.addressBar"
|
||||
case .browser(.findField):
|
||||
return "browser.findField"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetCommandPaletteSearchFocus() {
|
||||
applyCommandPaletteInputFocusPolicy(.search)
|
||||
|
|
@ -8079,6 +8124,7 @@ private enum SidebarHelpMenuAction {
|
|||
case githubIssues
|
||||
case checkForUpdates
|
||||
case sendFeedback
|
||||
case welcome
|
||||
}
|
||||
|
||||
private struct SidebarFeedbackComposerSheet: View {
|
||||
|
|
@ -8455,6 +8501,122 @@ private struct SidebarFeedbackComposerSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridgeError: LocalizedError {
|
||||
case invalidEmail
|
||||
case emptyMessage
|
||||
case messageTooLong
|
||||
case tooManyImages
|
||||
case invalidImagePath(String)
|
||||
case submissionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidEmail:
|
||||
return "Enter a valid email address."
|
||||
case .emptyMessage:
|
||||
return "Enter a message before sending."
|
||||
case .messageTooLong:
|
||||
return "Your message is too long."
|
||||
case .tooManyImages:
|
||||
return "You can attach up to 10 images."
|
||||
case .invalidImagePath(let path):
|
||||
return "Could not attach image: \(path)"
|
||||
case .submissionFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridge {
|
||||
static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) {
|
||||
NotificationCenter.default.post(name: .feedbackComposerRequested, object: window)
|
||||
}
|
||||
|
||||
static func submit(
|
||||
email: String,
|
||||
message: String,
|
||||
imagePaths: [String]
|
||||
) async throws -> Int {
|
||||
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard isValidEmail(trimmedEmail) else {
|
||||
throw FeedbackComposerBridgeError.invalidEmail
|
||||
}
|
||||
guard normalizedMessage.isEmpty == false else {
|
||||
throw FeedbackComposerBridgeError.emptyMessage
|
||||
}
|
||||
guard message.count <= FeedbackComposerSettings.maxMessageLength else {
|
||||
throw FeedbackComposerBridgeError.messageTooLong
|
||||
}
|
||||
guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else {
|
||||
throw FeedbackComposerBridgeError.tooManyImages
|
||||
}
|
||||
|
||||
let attachments = try imagePaths.map { rawPath in
|
||||
let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL
|
||||
do {
|
||||
return try FeedbackComposerAttachment(url: resolvedURL)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await FeedbackComposerClient.submit(
|
||||
email: trimmedEmail,
|
||||
message: normalizedMessage,
|
||||
attachments: attachments
|
||||
)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error))
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey)
|
||||
return attachments.count
|
||||
}
|
||||
|
||||
private static func isValidEmail(_ rawValue: String) -> Bool {
|
||||
let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard email.isEmpty == false else { return false }
|
||||
let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
|
||||
return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email)
|
||||
}
|
||||
|
||||
private static func userFacingMessage(for error: Error) -> String {
|
||||
guard let submissionError = error as? FeedbackComposerSubmissionError else {
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
|
||||
switch submissionError {
|
||||
case .invalidEndpoint:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
case .invalidResponse:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .attachmentReadFailed:
|
||||
return "One of the selected files could not be attached."
|
||||
case .attachmentPreparationFailed:
|
||||
return "These images are too large to send together. Remove a few and try again."
|
||||
case .transport(let transportError):
|
||||
if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost {
|
||||
return "Couldn't send feedback. Check your connection and try again."
|
||||
}
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .rejected(let statusCode):
|
||||
switch statusCode {
|
||||
case 400, 413, 415:
|
||||
return "Check your message and attachments, then try again."
|
||||
case 429:
|
||||
return "Too many feedback attempts. Please try again later."
|
||||
case 500...599:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
default:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarHelpMenuButton: View {
|
||||
private let docsURL = URL(string: "https://cmux.dev/docs")
|
||||
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
|
||||
|
|
@ -8503,6 +8665,12 @@ private struct SidebarHelpMenuButton: View {
|
|||
|
||||
private var helpPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
|
||||
action: .welcome,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
|
||||
isExternalLink: false
|
||||
)
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"),
|
||||
action: .sendFeedback,
|
||||
|
|
@ -8614,14 +8782,17 @@ private struct SidebarHelpMenuButton: View {
|
|||
private func perform(_ action: SidebarHelpMenuAction) {
|
||||
switch action {
|
||||
case .keyboardShortcuts:
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
isPopoverPresented = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .docs:
|
||||
|
|
@ -8643,6 +8814,13 @@ private struct SidebarHelpMenuButton: View {
|
|||
case .sendFeedback:
|
||||
isPopoverPresented = false
|
||||
onSendFeedback()
|
||||
case .welcome:
|
||||
isPopoverPresented = false
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openWelcomeWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9538,6 +9716,7 @@ private struct TabItemView: View {
|
|||
dropIndicator = nil
|
||||
return SidebarTabDragPayload.provider(for: tab.id)
|
||||
}
|
||||
.internalOnlyTabDrag()
|
||||
.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate(
|
||||
targetTabId: tab.id,
|
||||
tabManager: tabManager,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
struct BrowserSearchOverlay: View {
|
||||
let panelId: UUID
|
||||
@ObservedObject var searchState: BrowserSearchState
|
||||
let focusRequestGeneration: UInt64
|
||||
let canApplyFocusRequest: (UInt64) -> Bool
|
||||
let onNext: () -> Void
|
||||
let onPrevious: () -> Void
|
||||
let onClose: () -> Void
|
||||
let onFieldDidFocus: () -> Void
|
||||
@State private var corner: Corner = .topRight
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
@State private var barSize: CGSize = .zero
|
||||
|
|
@ -14,12 +18,58 @@ struct BrowserSearchOverlay: View {
|
|||
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
|
||||
#if DEBUG
|
||||
private func debugFirstResponderSummary() -> String {
|
||||
guard let window = NSApp.keyWindow else { return "nil" }
|
||||
guard let firstResponder = window.firstResponder else { return "nil" }
|
||||
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
|
||||
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "fieldEditor(delegate=\(delegateSummary))"
|
||||
}
|
||||
return String(describing: type(of: firstResponder))
|
||||
}
|
||||
#endif
|
||||
|
||||
private func logFocusState(_ event: String) {
|
||||
#if DEBUG
|
||||
let keyWindow = NSApp.keyWindow
|
||||
dlog(
|
||||
"browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(debugFirstResponderSummary()) " +
|
||||
"focused=\(isSearchFieldFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) {
|
||||
guard maxAttempts > 0 else { return }
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
#if DEBUG
|
||||
logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)")
|
||||
isSearchFieldFocused = true
|
||||
#if DEBUG
|
||||
DispatchQueue.main.async {
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
return
|
||||
}
|
||||
logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)")
|
||||
}
|
||||
#endif
|
||||
guard maxAttempts > 1 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
#if DEBUG
|
||||
logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,16 +152,24 @@ struct BrowserSearchOverlay: View {
|
|||
.clipShape(clipShape)
|
||||
.shadow(radius: 4)
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
|
||||
#endif
|
||||
requestSearchFieldFocus()
|
||||
#endif
|
||||
logFocusState("appear")
|
||||
requestSearchFieldFocus(origin: "appear")
|
||||
}
|
||||
.onChange(of: isSearchFieldFocused) { _, focused in
|
||||
logFocusState("focusState.change next=\(focused ? 1 : 0)")
|
||||
if focused {
|
||||
onFieldDidFocus()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
|
||||
guard let notifiedPanelId = notification.object as? UUID,
|
||||
notifiedPanelId == panelId else { return }
|
||||
logFocusState("notification.received")
|
||||
DispatchQueue.main.async {
|
||||
requestSearchFieldFocus()
|
||||
requestSearchFieldFocus(origin: "notification")
|
||||
}
|
||||
}
|
||||
.background(
|
||||
|
|
|
|||
|
|
@ -328,10 +328,19 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
|
|||
field.currentEditor() != nil ||
|
||||
((fr as? NSTextView)?.delegate as? NSTextField) === field
|
||||
#if DEBUG
|
||||
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
|
||||
dlog(
|
||||
"find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
|
||||
"alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))"
|
||||
)
|
||||
#endif
|
||||
guard !alreadyFocused else { return }
|
||||
window.makeFirstResponder(field)
|
||||
let result = window.makeFirstResponder(field)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
return field
|
||||
|
|
|
|||
|
|
@ -708,6 +708,7 @@ class GhosttyApp {
|
|||
private let backgroundLogLock = NSLock()
|
||||
private var backgroundLogSequence: UInt64 = 0
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
private var bellAudioSound: NSSound?
|
||||
private var backgroundEventCounter: UInt64 = 0
|
||||
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
||||
private var defaultBackgroundScopeSource: String = "initialize"
|
||||
|
|
@ -1524,6 +1525,75 @@ class GhosttyApp {
|
|||
return found && enabled
|
||||
}
|
||||
|
||||
func appleScriptAutomationEnabled() -> Bool {
|
||||
guard let config else { return false }
|
||||
var enabled = false
|
||||
let key = "macos-applescript"
|
||||
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return enabled
|
||||
}
|
||||
|
||||
fileprivate func shellIntegrationMode() -> String {
|
||||
guard let config else { return "detect" }
|
||||
var value: UnsafePointer<Int8>?
|
||||
let key = "shell-integration"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let value else {
|
||||
return "detect"
|
||||
}
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
private func bellFeatures() -> CUnsignedInt {
|
||||
guard let config else { return 0 }
|
||||
var features: CUnsignedInt = 0
|
||||
let key = "bell-features"
|
||||
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return features
|
||||
}
|
||||
|
||||
private func bellAudioPath() -> String? {
|
||||
guard let config else { return nil }
|
||||
var value = ghostty_config_path_s()
|
||||
let key = "bell-audio-path"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let rawPath = value.path else {
|
||||
return nil
|
||||
}
|
||||
let path = String(cString: rawPath)
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
|
||||
private func bellAudioVolume() -> Float {
|
||||
guard let config else { return 0.5 }
|
||||
var value: Double = 0.5
|
||||
let key = "bell-audio-volume"
|
||||
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return Float(min(1.0, max(0.0, value)))
|
||||
}
|
||||
|
||||
private func ringBell() {
|
||||
let features = bellFeatures()
|
||||
|
||||
if (features & (1 << 0)) != 0 {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
if (features & (1 << 1)) != 0,
|
||||
let path = bellAudioPath(),
|
||||
let sound = NSSound(contentsOfFile: path, byReference: false) {
|
||||
sound.volume = bellAudioVolume()
|
||||
bellAudioSound = sound
|
||||
if !sound.play() {
|
||||
bellAudioSound = nil
|
||||
}
|
||||
}
|
||||
|
||||
if (features & (1 << 2)) != 0 {
|
||||
NSApp.requestUserAttention(.informationalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDefaultBackground(
|
||||
color: NSColor,
|
||||
opacity: Double,
|
||||
|
|
@ -1690,6 +1760,13 @@ class GhosttyApp {
|
|||
}
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RING_BELL {
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
|
||||
let soft = action.action.reload_config.soft
|
||||
logThemeAction("reload request target=app soft=\(soft)")
|
||||
|
|
@ -1797,6 +1874,11 @@ class GhosttyApp {
|
|||
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
||||
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
case GHOSTTY_ACTION_GOTO_SPLIT:
|
||||
guard let tabId = surfaceView.tabId,
|
||||
let surfaceId = surfaceView.terminalSurface?.id,
|
||||
|
|
@ -2766,6 +2848,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
?? "/bin/zsh"
|
||||
let shellName = URL(fileURLWithPath: shell).lastPathComponent
|
||||
if shellName == "zsh" {
|
||||
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
||||
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
||||
}
|
||||
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
|
||||
?? getenv("ZDOTDIR").map { String(cString: $0) }
|
||||
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
|
||||
|
|
@ -3310,9 +3395,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private var eventMonitor: Any?
|
||||
private var trackingArea: NSTrackingArea?
|
||||
private var windowObserver: NSObjectProtocol?
|
||||
private var lastScrollEventTime: CFTimeInterval = 0
|
||||
private var lastScrollEventTime: CFTimeInterval = 0
|
||||
private var visibleInUI: Bool = true
|
||||
private var pendingSurfaceSize: CGSize?
|
||||
private var deferredSurfaceSizeRetryQueued = false
|
||||
private var lastDrawableSize: CGSize = .zero
|
||||
private var isFindEscapeSuppressionArmed = false
|
||||
#if DEBUG
|
||||
|
|
@ -3610,11 +3696,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return currentBounds
|
||||
}
|
||||
|
||||
private static func hasActiveTabDragPasteboard() -> Bool {
|
||||
private static func hasTabDragPasteboardTypes() -> Bool {
|
||||
let types = NSPasteboard(name: .drag).types ?? []
|
||||
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
|
||||
}
|
||||
|
||||
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
|
||||
switch eventType {
|
||||
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
|
||||
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
|
||||
// or other layout churn. Only defer terminal resizes while an actual drag event
|
||||
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
|
||||
guard hasTabDragPasteboardTypes() else { return false }
|
||||
return isDragResizeEvent(NSApp.currentEvent?.type)
|
||||
}
|
||||
|
||||
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
|
||||
guard window != nil else { return }
|
||||
guard !deferredSurfaceSizeRetryQueued else { return }
|
||||
deferredSurfaceSizeRetryQueued = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.deferredSurfaceSizeRetryQueued = false
|
||||
_ = self.updateSurfaceSize()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
|
||||
guard let terminalSurface = terminalSurface else { return false }
|
||||
|
|
@ -3634,7 +3748,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return false
|
||||
}
|
||||
pendingSurfaceSize = size
|
||||
guard !Self.hasActiveTabDragPasteboard() else {
|
||||
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
|
||||
scheduleDeferredSurfaceSizeRetryIfNeeded()
|
||||
#if DEBUG
|
||||
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
|
||||
if lastSizeSkipSignature != signature {
|
||||
|
|
@ -4376,6 +4491,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id
|
||||
)
|
||||
}
|
||||
if event.keyCode != 53 {
|
||||
endFindEscapeSuppression()
|
||||
}
|
||||
|
|
@ -4537,6 +4658,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
|
||||
// Use accumulated text from insertText (for IME), or compute text for key
|
||||
let accumulatedText = keyTextAccumulator ?? []
|
||||
var shouldRefreshAfterTextInput = false
|
||||
if !accumulatedText.isEmpty {
|
||||
// Accumulated text comes from insertText (IME composition result).
|
||||
// These never have "composing" set to true because these are the
|
||||
|
|
@ -4544,6 +4666,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
keyEvent.composing = false
|
||||
for text in accumulatedText {
|
||||
if shouldSendText(text) {
|
||||
shouldRefreshAfterTextInput = true
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
|
|
@ -4564,6 +4687,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
)
|
||||
if let text = textForKeyEvent(translationEvent) {
|
||||
if shouldSendText(text), !suppressShiftSpaceFallbackText {
|
||||
shouldRefreshAfterTextInput = true
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
|
|
@ -4578,6 +4702,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
}
|
||||
|
||||
if shouldRefreshAfterTextInput {
|
||||
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
|
||||
}
|
||||
|
||||
// Rendering is driven by Ghostty's wakeups/renderer.
|
||||
}
|
||||
|
||||
|
|
@ -4817,11 +4945,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
#endif
|
||||
|
||||
private func requestPointerFocusRecovery() {
|
||||
#if DEBUG
|
||||
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
||||
#endif
|
||||
onFocus?()
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let debugPoint = convert(event.locationInWindow, from: nil)
|
||||
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
|
||||
#endif
|
||||
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
|
||||
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
|
||||
// repairs workspace/pane active state before key routing runs.
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
|
|
@ -4846,10 +4985,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
override func rightMouseDown(with event: NSEvent) {
|
||||
guard let surface = surface else { return }
|
||||
if !ghostty_surface_mouse_captured(surface) {
|
||||
requestPointerFocusRecovery()
|
||||
super.rightMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||
|
|
@ -4871,6 +5012,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.otherMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
guard let surface = surface else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
|
|
@ -5330,6 +5472,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private var isLiveScrolling = false
|
||||
private var lastSentRow: Int?
|
||||
private var isActive = true
|
||||
private var lastFocusRefreshAt: CFTimeInterval = 0
|
||||
private var activeDropZone: DropZone?
|
||||
private var pendingDropZone: DropZone?
|
||||
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
||||
|
|
@ -6286,6 +6429,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
var debugPortalVisibleInUI: Bool {
|
||||
surfaceView.isVisibleInUI
|
||||
}
|
||||
|
||||
var debugPortalFrameInWindow: CGRect {
|
||||
guard window != nil else { return .zero }
|
||||
return convert(bounds, to: nil)
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
let wasActive = isActive
|
||||
isActive = active
|
||||
|
|
@ -6301,10 +6453,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#endif
|
||||
if active {
|
||||
scheduleAutomaticFirstResponderApply(reason: "setActive")
|
||||
} else if let window,
|
||||
let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
window.makeFirstResponder(nil)
|
||||
} else {
|
||||
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6354,15 +6504,29 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#if DEBUG
|
||||
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
||||
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
|
||||
dlog(
|
||||
"find.moveFocus to=\(surfaceShort) " +
|
||||
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"searchState=\(searchActive ? "active" : "nil") " +
|
||||
"delayMs=\(Int((delay ?? 0) * 1000))"
|
||||
)
|
||||
#endif
|
||||
let work = { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let window = self.window else { return }
|
||||
#if DEBUG
|
||||
let before = String(describing: window.firstResponder)
|
||||
#endif
|
||||
if let previous, previous !== self {
|
||||
_ = previous.surfaceView.resignFirstResponder()
|
||||
}
|
||||
window.makeFirstResponder(self.surfaceView)
|
||||
let result = window.makeFirstResponder(self.surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
if let delay, delay > 0 {
|
||||
|
|
@ -6506,6 +6670,12 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
guard isActive else { return }
|
||||
guard let window else { return }
|
||||
guard surfaceView.isVisibleInUI else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=not_visible attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
retry()
|
||||
return
|
||||
}
|
||||
|
|
@ -6545,25 +6715,59 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
// Search focus restoration — only after confirming this is the active tab/pane.
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
restoreSearchFocus(window: window)
|
||||
return
|
||||
}
|
||||
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
|
||||
return
|
||||
}
|
||||
|
||||
if !window.isKeyWindow {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
_ = window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
|
||||
"attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if !isSurfaceViewFirstResponder() {
|
||||
retry()
|
||||
} else {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
|
||||
guard let delegate = AppDelegate.shared,
|
||||
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
||||
tabManager.selectedTabId == tabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
||||
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
|
||||
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
|
||||
}) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
|
||||
tab.bonsplitController.focusedPaneId == paneId
|
||||
}
|
||||
|
||||
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
|
||||
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
|
||||
func suppressReparentFocus() {
|
||||
|
|
@ -6596,6 +6800,33 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
private func reassertTerminalSurfaceFocus(reason: String) {
|
||||
guard let terminalSurface = surfaceView.terminalSurface else { return }
|
||||
#if DEBUG
|
||||
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
||||
#endif
|
||||
terminalSurface.setFocus(true)
|
||||
refreshSurfaceAfterFocusIfNeeded(reason: reason)
|
||||
}
|
||||
|
||||
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
isActive,
|
||||
let window,
|
||||
window.isKeyWindow,
|
||||
surfaceView.isVisibleInUI else { return }
|
||||
|
||||
let now = CACurrentMediaTime()
|
||||
if now - lastFocusRefreshAt < 0.05 {
|
||||
return
|
||||
}
|
||||
lastFocusRefreshAt = now
|
||||
#if DEBUG
|
||||
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
||||
#endif
|
||||
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
|
||||
}
|
||||
|
||||
private func applyFirstResponderIfNeeded() {
|
||||
let hasUsablePortalGeometry: Bool = {
|
||||
let size = bounds.size
|
||||
|
|
@ -6616,6 +6847,14 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return
|
||||
}
|
||||
guard let window, window.isKeyWindow else { return }
|
||||
guard let tabId = surfaceView.tabId,
|
||||
let panelId = surfaceView.terminalSurface?.id,
|
||||
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
|
||||
#if DEBUG
|
||||
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
// Find bar is open. Restore focus based on what the user last intended.
|
||||
restoreSearchFocus(window: window)
|
||||
|
|
@ -6623,6 +6862,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
|
||||
return
|
||||
}
|
||||
// Don't steal focus from a search overlay on another surface in this window.
|
||||
|
|
@ -6636,6 +6876,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
|
||||
#endif
|
||||
window.makeFirstResponder(surfaceView)
|
||||
if isSurfaceViewFirstResponder() {
|
||||
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore focus when window becomes key and the find bar is open.
|
||||
|
|
@ -6644,6 +6887,18 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
switch searchFocusTarget {
|
||||
case .searchField:
|
||||
if let firstResponder = window.firstResponder,
|
||||
isSearchOverlayOrDescendant(firstResponder),
|
||||
!isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
||||
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
// Explicitly unfocus the terminal so cursor stops blinking immediately.
|
||||
// The notification observer also does this, but it runs async when posted from main.
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
|
|
@ -6653,16 +6908,152 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
|
||||
"via=notification firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
case .terminal:
|
||||
window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
if let firstResponder = window?.firstResponder as? NSView,
|
||||
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
|
||||
return .surface
|
||||
}
|
||||
if let firstResponder = window?.firstResponder,
|
||||
isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
return .findField
|
||||
}
|
||||
if searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
case .findField:
|
||||
guard surfaceView.terminalSurface?.searchState != nil else { return }
|
||||
searchFocusTarget = .searchField
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
setActive(true)
|
||||
applyFirstResponderIfNeeded()
|
||||
return true
|
||||
case .findField:
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
terminalSurface.searchState != nil else {
|
||||
return false
|
||||
}
|
||||
searchFocusTarget = .searchField
|
||||
setActive(true)
|
||||
if let window {
|
||||
restoreSearchFocus(window: window)
|
||||
} else {
|
||||
terminalSurface.setFocus(false)
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
|
||||
if isCurrentSurfaceSearchResponder(responder) {
|
||||
return .findField
|
||||
}
|
||||
|
||||
let resolvedResponder: NSResponder
|
||||
if let editor = responder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
resolvedResponder = editedView
|
||||
} else {
|
||||
resolvedResponder = responder
|
||||
}
|
||||
|
||||
guard let view = resolvedResponder as? NSView else { return nil }
|
||||
if view === surfaceView || view.isDescendant(of: surfaceView) {
|
||||
return .surface
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard let firstResponder = window.firstResponder,
|
||||
ownedPanelFocusIntent(for: firstResponder) == intent else {
|
||||
return false
|
||||
}
|
||||
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private func resignOwnedFirstResponderIfNeeded(reason: String) {
|
||||
guard let window,
|
||||
let firstResponder = window.firstResponder else { return }
|
||||
|
||||
let ownsSurfaceResponder: Bool = {
|
||||
guard let view = firstResponder as? NSView else { return false }
|
||||
return view === surfaceView || view.isDescendant(of: surfaceView)
|
||||
}()
|
||||
|
||||
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
/// Check if a responder is inside a search overlay hosting view.
|
||||
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
|
||||
/// window.firstResponder is the shared NSTextView field editor, not the text field.
|
||||
|
|
@ -6678,11 +7069,27 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
var current: NSView? = view
|
||||
while let v = current {
|
||||
if v is NSHostingView<SurfaceSearchOverlay> { return true }
|
||||
let typeName = String(describing: type(of: v))
|
||||
if typeName.contains("BrowserSearchOverlay") { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
|
||||
let resolvedResponder: NSResponder
|
||||
if let editor = responder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
resolvedResponder = editedView
|
||||
} else {
|
||||
resolvedResponder = responder
|
||||
}
|
||||
|
||||
guard let view = resolvedResponder as? NSView else { return false }
|
||||
return view.isDescendant(of: self)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugRenderStats {
|
||||
let drawCount: Int
|
||||
|
|
|
|||
|
|
@ -155,10 +155,20 @@ struct NotificationsPage: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ShortcutAnnotation: View {
|
||||
struct ShortcutAnnotation: View {
|
||||
let text: String
|
||||
var accessibilityIdentifier: String? = nil
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if let accessibilityIdentifier {
|
||||
badge.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
badge
|
||||
}
|
||||
}
|
||||
|
||||
private var badge: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
|
|
|
|||
|
|
@ -1705,10 +1705,17 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
/// cleared only after BrowserPanelView acknowledges handling it.
|
||||
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
|
||||
|
||||
/// Semantic in-panel focus target used by split switching and transient overlays.
|
||||
private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView
|
||||
|
||||
/// Incremented whenever async browser find focus ownership changes.
|
||||
@Published private(set) var searchFocusRequestGeneration: UInt64 = 0
|
||||
|
||||
/// Find-in-page state. Non-nil when the find bar is visible.
|
||||
@Published var searchState: BrowserSearchState? = nil {
|
||||
didSet {
|
||||
if let searchState {
|
||||
preferredFocusIntent = .findField
|
||||
NSLog("Find: browser search state created panel=%@", id.uuidString)
|
||||
searchNeedleCancellable = searchState.$needle
|
||||
.removeDuplicates()
|
||||
|
|
@ -1728,6 +1735,10 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
} else if oldValue != nil {
|
||||
searchNeedleCancellable = nil
|
||||
if preferredFocusIntent == .findField {
|
||||
preferredFocusIntent = .webView
|
||||
}
|
||||
invalidateSearchFocusRequests(reason: "searchStateCleared")
|
||||
NSLog("Find: browser search state cleared panel=%@", id.uuidString)
|
||||
executeFindClear()
|
||||
}
|
||||
|
|
@ -1741,7 +1752,18 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
let inWindow: Bool
|
||||
let area: CGFloat
|
||||
}
|
||||
private struct PortalHostLock {
|
||||
let hostId: ObjectIdentifier
|
||||
let paneId: UUID
|
||||
}
|
||||
private enum DeveloperToolsPresentation {
|
||||
case unknown
|
||||
case attached
|
||||
case detached
|
||||
}
|
||||
private var activePortalHostLease: PortalHostLease?
|
||||
private var pendingDistinctPortalHostReplacementPaneId: UUID?
|
||||
private var lockedPortalHost: PortalHostLock?
|
||||
private var webViewCancellables = Set<AnyCancellable>()
|
||||
private var navigationDelegate: BrowserNavigationDelegate?
|
||||
private var uiDelegate: BrowserUIDelegate?
|
||||
|
|
@ -1765,7 +1787,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private var insecureHTTPAlertFactory: () -> NSAlert
|
||||
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
|
||||
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
||||
private var preferredDeveloperToolsVisible: Bool = false
|
||||
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
|
||||
private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown
|
||||
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
||||
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||
private var developerToolsRestoreRetryAttempt: Int = 0
|
||||
|
|
@ -1773,6 +1796,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
||||
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
||||
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
|
||||
private var developerToolsDetachedOpenGraceDeadline: Date?
|
||||
private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol?
|
||||
private var preferredAttachedDeveloperToolsWidth: CGFloat?
|
||||
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
|
||||
private var browserThemeMode: BrowserThemeMode
|
||||
|
||||
var displayTitle: String {
|
||||
|
|
@ -1796,6 +1824,22 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
lease.inWindow && lease.area > portalHostAreaThreshold
|
||||
}
|
||||
|
||||
func preparePortalHostReplacementForNextDistinctClaim(
|
||||
inPane paneId: PaneID,
|
||||
reason: String
|
||||
) {
|
||||
pendingDistinctPortalHostReplacementPaneId = paneId.id
|
||||
if lockedPortalHost?.paneId == paneId.id {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func claimPortalHost(
|
||||
hostId: ObjectIdentifier,
|
||||
paneId: PaneID,
|
||||
|
|
@ -1811,6 +1855,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
)
|
||||
|
||||
if let current = activePortalHostLease {
|
||||
if let lock = lockedPortalHost,
|
||||
(lock.hostId != current.hostId || lock.paneId != current.paneId) {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
|
||||
if current.hostId == hostId {
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
|
|
@ -1818,12 +1867,47 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
|
||||
let currentUsable = Self.portalHostIsUsable(current)
|
||||
let nextUsable = Self.portalHostIsUsable(next)
|
||||
let isSamePaneReplacement = current.paneId == paneId.id
|
||||
let shouldForceDistinctReplacement =
|
||||
isSamePaneReplacement &&
|
||||
pendingDistinctPortalHostReplacementPaneId == paneId.id &&
|
||||
inWindow
|
||||
if shouldForceDistinctReplacement {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " +
|
||||
"forced=1"
|
||||
)
|
||||
#endif
|
||||
activePortalHostLease = next
|
||||
pendingDistinctPortalHostReplacementPaneId = nil
|
||||
lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id)
|
||||
return true
|
||||
}
|
||||
|
||||
let lockBlocksSamePaneReplacement =
|
||||
isSamePaneReplacement &&
|
||||
currentUsable &&
|
||||
lockedPortalHost?.hostId == current.hostId &&
|
||||
lockedPortalHost?.paneId == current.paneId
|
||||
let shouldReplace =
|
||||
current.paneId != paneId.id ||
|
||||
!currentUsable ||
|
||||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
|
||||
(
|
||||
!lockBlocksSamePaneReplacement &&
|
||||
nextUsable &&
|
||||
next.area > (current.area * Self.portalHostReplacementAreaGainRatio)
|
||||
)
|
||||
|
||||
if shouldReplace {
|
||||
if lockedPortalHost?.hostId == current.hostId &&
|
||||
lockedPortalHost?.paneId == current.paneId {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
|
|
@ -1843,7 +1927,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))"
|
||||
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " +
|
||||
"locked=\(lockBlocksSamePaneReplacement ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
|
|
@ -1865,6 +1950,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
|
||||
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
|
||||
activePortalHostLease = nil
|
||||
if lockedPortalHost?.hostId == hostId {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
|
||||
|
|
@ -2023,6 +2111,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
self.uiDelegate = browserUIDelegate
|
||||
|
||||
bindWebView(webView)
|
||||
installDetachedDeveloperToolsWindowCloseObserver()
|
||||
applyBrowserThemeModeIfNeeded()
|
||||
insecureHTTPAlertWindowProvider = { [weak self] in
|
||||
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
|
|
@ -2298,12 +2387,16 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
if Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
noteWebViewFocused()
|
||||
return
|
||||
}
|
||||
window.makeFirstResponder(webView)
|
||||
if window.makeFirstResponder(webView) {
|
||||
noteWebViewFocused()
|
||||
}
|
||||
}
|
||||
|
||||
func unfocus() {
|
||||
invalidateSearchFocusRequests(reason: "panelUnfocus")
|
||||
guard let window = webView.window else { return }
|
||||
if Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
|
|
@ -2700,6 +2793,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
deinit {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
if let detachedDeveloperToolsWindowCloseObserver {
|
||||
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
|
||||
}
|
||||
let webView = webView
|
||||
Task { @MainActor in
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
|
|
@ -2847,6 +2943,160 @@ extension BrowserPanel {
|
|||
webView.stopLoading()
|
||||
}
|
||||
|
||||
private static func windowContainsInspectorViews(_ root: NSView) -> Bool {
|
||||
if String(describing: type(of: root)).contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
for subview in root.subviews where windowContainsInspectorViews(subview) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool {
|
||||
guard window.title.hasPrefix("Web Inspector") else { return false }
|
||||
guard let contentView = window.contentView else { return false }
|
||||
return windowContainsInspectorViews(contentView)
|
||||
}
|
||||
|
||||
private func detachedDeveloperToolsWindows() -> [NSWindow] {
|
||||
let mainWindow = webView.window
|
||||
return NSApp.windows.filter { candidate in
|
||||
if let mainWindow, candidate === mainWindow {
|
||||
return false
|
||||
}
|
||||
return Self.isDetachedInspectorWindow(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func hasAttachedDeveloperToolsLayout() -> Bool {
|
||||
guard let container = webView.superview else { return false }
|
||||
return Self.visibleDescendants(in: container)
|
||||
.contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
|
||||
}
|
||||
|
||||
private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) {
|
||||
guard preferredDeveloperToolsPresentation != next else { return }
|
||||
preferredDeveloperToolsPresentation = next
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncDeveloperToolsPresentationPreferenceFromUI() {
|
||||
if !detachedDeveloperToolsWindows().isEmpty {
|
||||
setPreferredDeveloperToolsPresentation(.detached)
|
||||
} else if hasAttachedDeveloperToolsLayout() {
|
||||
setPreferredDeveloperToolsPresentation(.attached)
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func installDetachedDeveloperToolsWindowCloseObserver() {
|
||||
guard detachedDeveloperToolsWindowCloseObserver == nil else { return }
|
||||
detachedDeveloperToolsWindowCloseObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self,
|
||||
let window = notification.object as? NSWindow else { return }
|
||||
let isDetachedInspectorWindow = MainActor.assumeIsolated {
|
||||
Self.isDetachedInspectorWindow(window)
|
||||
}
|
||||
guard isDetachedInspectorWindow else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.preferredDeveloperToolsPresentation == .detached else { return }
|
||||
guard self.preferredDeveloperToolsVisible else { return }
|
||||
guard !self.isDeveloperToolsVisible() else { return }
|
||||
self.developerToolsDetachedOpenGraceDeadline = nil
|
||||
self.preferredDeveloperToolsVisible = false
|
||||
self.cancelDeveloperToolsRestoreRetry()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " +
|
||||
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldDismissDetachedDeveloperToolsWindows() -> Bool {
|
||||
preferredDeveloperToolsPresentation == .attached
|
||||
}
|
||||
|
||||
private func dismissDetachedDeveloperToolsWindowsIfNeeded() {
|
||||
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
|
||||
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(),
|
||||
let mainWindow = webView.window else { return }
|
||||
for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " +
|
||||
"title=\(window.title) frame=\(NSStringFromRect(window.frame))"
|
||||
)
|
||||
#endif
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDetachedDeveloperToolsWindowDismissal() {
|
||||
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
|
||||
for delay in [0.0, 0.15] {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dismissDetachedDeveloperToolsWindowsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) {
|
||||
guard preferredDeveloperToolsPresentation == .unknown else { return }
|
||||
let attachSelector = NSSelectorFromString("attach")
|
||||
guard inspector.responds(to: attachSelector) else { return }
|
||||
inspector.cmuxCallVoid(selector: attachSelector)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func revealDeveloperTools(_ inspector: NSObject) -> Bool {
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
return true
|
||||
}
|
||||
|
||||
prepareDeveloperToolsForRevealIfNeeded(inspector)
|
||||
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if preferredDeveloperToolsPresentation == .detached {
|
||||
developerToolsDetachedOpenGraceDeadline = visibleAfterShow
|
||||
? nil
|
||||
: Date().addingTimeInterval(developerToolsDetachedOpenGracePeriod)
|
||||
} else {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
return visibleAfterShow
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func concealDeveloperTools(_ inspector: NSObject) -> Bool {
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
guard inspector.cmuxCallBool(selector: isVisibleSelector) ?? false else { return true }
|
||||
|
||||
for rawSelector in ["hide", "close"] {
|
||||
let selector = NSSelectorFromString(rawSelector)
|
||||
guard inspector.responds(to: selector) else { continue }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleDeveloperTools() -> Bool {
|
||||
#if DEBUG
|
||||
|
|
@ -2859,14 +3109,20 @@ extension BrowserPanel {
|
|||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
let targetVisible = !visible
|
||||
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
if targetVisible {
|
||||
_ = revealDeveloperTools(inspector)
|
||||
} else {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
guard concealDeveloperTools(inspector) else { return false }
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if visibleAfterToggle {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
|
|
@ -2896,13 +3152,13 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if !visible {
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
guard revealDeveloperTools(inspector) else { return false }
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -2934,6 +3190,8 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return }
|
||||
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
|
||||
if visible {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
preferredDeveloperToolsVisible = true
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
|
|
@ -2962,6 +3220,8 @@ extension BrowserPanel {
|
|||
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
|
|
@ -2971,26 +3231,37 @@ extension BrowserPanel {
|
|||
return
|
||||
}
|
||||
|
||||
let selector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: selector) else {
|
||||
let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false
|
||||
if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling {
|
||||
preferredDeveloperToolsVisible = false
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " +
|
||||
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
}
|
||||
#endif
|
||||
// WebKit inspector "show" can trigger transient first-responder churn while
|
||||
// WebKit inspector show can trigger transient first-responder churn while
|
||||
// panel attachment is still stabilizing. Keep this auto-restore path from
|
||||
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
|
||||
cmuxWithWindowFirstResponderBypass {
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
_ = revealDeveloperTools(inspector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visibleAfterShow {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -3007,11 +3278,11 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
let selector = NSSelectorFromString("close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
guard concealDeveloperTools(inspector) else { return false }
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return true
|
||||
|
|
@ -3036,6 +3307,38 @@ extension BrowserPanel {
|
|||
forceDeveloperToolsRefreshOnNextAttach
|
||||
}
|
||||
|
||||
func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
|
||||
preferredDeveloperToolsVisible &&
|
||||
(
|
||||
forceDeveloperToolsRefreshOnNextAttach ||
|
||||
developerToolsRestoreRetryWorkItem != nil ||
|
||||
webView.superview == nil ||
|
||||
webView.window == nil
|
||||
)
|
||||
}
|
||||
|
||||
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
|
||||
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false }
|
||||
if preferredDeveloperToolsPresentation == .detached {
|
||||
return false
|
||||
}
|
||||
return detachedDeveloperToolsWindows().isEmpty
|
||||
}
|
||||
|
||||
func recordPreferredAttachedDeveloperToolsWidth(_ width: CGFloat, containerBounds: NSRect) {
|
||||
let normalizedWidth = max(0, width)
|
||||
preferredAttachedDeveloperToolsWidth = normalizedWidth
|
||||
guard containerBounds.width > 0 else {
|
||||
preferredAttachedDeveloperToolsWidthFraction = nil
|
||||
return
|
||||
}
|
||||
preferredAttachedDeveloperToolsWidthFraction = normalizedWidth / containerBounds.width
|
||||
}
|
||||
|
||||
func preferredAttachedDeveloperToolsWidthState() -> (width: CGFloat?, widthFraction: CGFloat?) {
|
||||
(preferredAttachedDeveloperToolsWidth, preferredAttachedDeveloperToolsWidthFraction)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func zoomIn() -> Bool {
|
||||
applyPageZoom(webView.pageZoom + pageZoomStep)
|
||||
|
|
@ -3082,21 +3385,52 @@ extension BrowserPanel {
|
|||
// MARK: - Find in Page
|
||||
|
||||
func startFind() {
|
||||
if searchState == nil {
|
||||
preferredFocusIntent = .findField
|
||||
let created = searchState == nil
|
||||
if created {
|
||||
searchState = BrowserSearchState()
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
let generation = beginSearchFocusRequest(reason: "startFind")
|
||||
#if DEBUG
|
||||
let window = webView.window
|
||||
dlog(
|
||||
"browser.find.start panel=\(id.uuidString.prefix(5)) " +
|
||||
"created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " +
|
||||
"generation=\(generation) " +
|
||||
"window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " +
|
||||
"firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
postBrowserSearchFocusNotification(reason: "immediate", generation: generation)
|
||||
// Focus notification can race with portal overlay mount. Re-post on the
|
||||
// next runloop and shortly after so the find field can claim first responder.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func postBrowserSearchFocusNotification() {
|
||||
private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) {
|
||||
guard canApplySearchFocusRequest(generation) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) generation=\(generation)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
let window = webView.window
|
||||
dlog(
|
||||
"browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(generation) " +
|
||||
"reason=\(reason) window=\(window?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
|
||||
}
|
||||
|
||||
|
|
@ -3117,6 +3451,7 @@ extension BrowserPanel {
|
|||
}
|
||||
|
||||
func hideFind() {
|
||||
invalidateSearchFocusRequests(reason: "hideFind")
|
||||
searchState = nil
|
||||
}
|
||||
|
||||
|
|
@ -3127,7 +3462,10 @@ extension BrowserPanel {
|
|||
if replaySearch, !state.needle.isEmpty {
|
||||
executeFindSearch(state.needle)
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
postBrowserSearchFocusNotification(
|
||||
reason: "restoreAfterNavigation",
|
||||
generation: searchFocusRequestGeneration
|
||||
)
|
||||
}
|
||||
|
||||
private func executeFindSearch(_ needle: String) {
|
||||
|
|
@ -3254,6 +3592,8 @@ extension BrowserPanel {
|
|||
|
||||
@discardableResult
|
||||
func requestAddressBarFocus() -> UUID {
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "requestAddressBarFocus")
|
||||
beginSuppressWebViewFocusForAddressBar()
|
||||
if let pendingAddressBarFocusRequestId {
|
||||
#if DEBUG
|
||||
|
|
@ -3275,6 +3615,173 @@ extension BrowserPanel {
|
|||
return requestId
|
||||
}
|
||||
|
||||
func noteWebViewFocused() {
|
||||
guard searchState == nil else { return }
|
||||
guard preferredFocusIntent != .webView else { return }
|
||||
preferredFocusIntent = .webView
|
||||
invalidateSearchFocusRequests(reason: "webViewFocused")
|
||||
}
|
||||
|
||||
func noteAddressBarFocused() {
|
||||
guard preferredFocusIntent != .addressBar else { return }
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "addressBarFocused")
|
||||
}
|
||||
|
||||
func noteFindFieldFocused() {
|
||||
guard preferredFocusIntent != .findField else { return }
|
||||
preferredFocusIntent = .findField
|
||||
}
|
||||
|
||||
func canApplySearchFocusRequest(_ generation: UInt64) -> Bool {
|
||||
generation != 0 &&
|
||||
generation == searchFocusRequestGeneration &&
|
||||
searchState != nil &&
|
||||
preferredFocusIntent == .findField
|
||||
}
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
|
||||
if searchState != nil && preferredFocusIntent == .findField {
|
||||
return .browser(.findField)
|
||||
}
|
||||
|
||||
if let window,
|
||||
Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
return .browser(.webView)
|
||||
}
|
||||
|
||||
return .browser(preferredFocusIntent)
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
if pendingAddressBarFocusRequestId != nil {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
if searchState != nil && preferredFocusIntent == .findField {
|
||||
return .browser(.findField)
|
||||
}
|
||||
return .browser(preferredFocusIntent)
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
guard case .browser(let target) = intent else { return }
|
||||
|
||||
switch target {
|
||||
case .webView:
|
||||
preferredFocusIntent = .webView
|
||||
invalidateSearchFocusRequests(reason: "prepareWebView")
|
||||
endSuppressWebViewFocusForAddressBar()
|
||||
case .addressBar:
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "prepareAddressBar")
|
||||
beginSuppressWebViewFocusForAddressBar()
|
||||
case .findField:
|
||||
preferredFocusIntent = .findField
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.prepare panel=\(id.uuidString.prefix(5)) " +
|
||||
"target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
guard case .browser(let target) = intent else { return false }
|
||||
|
||||
switch target {
|
||||
case .webView:
|
||||
noteWebViewFocused()
|
||||
focus()
|
||||
return true
|
||||
case .addressBar:
|
||||
let requestId = requestAddressBarFocus()
|
||||
NotificationCenter.default.post(name: .browserFocusAddressBar, object: id)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.restore panel=\(id.uuidString.prefix(5)) " +
|
||||
"target=addressBar request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
case .findField:
|
||||
startFind()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
|
||||
if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id {
|
||||
return .browser(.findField)
|
||||
}
|
||||
|
||||
if Self.responderChainContains(responder, target: webView) {
|
||||
return .browser(.webView)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard case .browser(let target) = intent else { return false }
|
||||
|
||||
switch target {
|
||||
case .findField:
|
||||
invalidateSearchFocusRequests(reason: "yieldFindField")
|
||||
let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window)
|
||||
#if DEBUG
|
||||
if yielded {
|
||||
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind")
|
||||
}
|
||||
#endif
|
||||
return yielded
|
||||
case .addressBar:
|
||||
guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false }
|
||||
let yielded = window.makeFirstResponder(nil)
|
||||
#if DEBUG
|
||||
if yielded {
|
||||
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar")
|
||||
}
|
||||
#endif
|
||||
return yielded
|
||||
case .webView:
|
||||
guard Self.responderChainContains(window.firstResponder, target: webView) else { return false }
|
||||
return window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func beginSearchFocusRequest(reason: String) -> UInt64 {
|
||||
searchFocusRequestGeneration &+= 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
return searchFocusRequestGeneration
|
||||
}
|
||||
|
||||
private func invalidateSearchFocusRequests(reason: String) {
|
||||
searchFocusRequestGeneration &+= 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
|
||||
guard pendingAddressBarFocusRequestId == requestId else {
|
||||
#if DEBUG
|
||||
|
|
@ -3767,7 +4274,7 @@ private extension BrowserPanel {
|
|||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
extension WKWebView {
|
||||
func cmuxInspectorObject() -> NSObject? {
|
||||
let selector = NSSelectorFromString("_inspector")
|
||||
guard responds(to: selector),
|
||||
|
|
@ -3776,6 +4283,16 @@ private extension WKWebView {
|
|||
}
|
||||
return inspector
|
||||
}
|
||||
|
||||
func cmuxInspectorFrontendWebView() -> WKWebView? {
|
||||
guard let inspector = cmuxInspectorObject() else { return nil }
|
||||
let selector = NSSelectorFromString("inspectorWebView")
|
||||
guard inspector.responds(to: selector),
|
||||
let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else {
|
||||
return nil
|
||||
}
|
||||
return inspectorWebView
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSObject {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
/// Type of panel content
|
||||
public enum PanelType: String, Codable, Sendable {
|
||||
|
|
@ -8,6 +9,23 @@ public enum PanelType: String, Codable, Sendable {
|
|||
case markdown
|
||||
}
|
||||
|
||||
public enum TerminalPanelFocusIntent: Equatable {
|
||||
case surface
|
||||
case findField
|
||||
}
|
||||
|
||||
public enum BrowserPanelFocusIntent: Equatable {
|
||||
case webView
|
||||
case addressBar
|
||||
case findField
|
||||
}
|
||||
|
||||
public enum PanelFocusIntent: Equatable {
|
||||
case panel
|
||||
case terminal(TerminalPanelFocusIntent)
|
||||
case browser(BrowserPanelFocusIntent)
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
case easeIn
|
||||
case easeOut
|
||||
|
|
@ -72,10 +90,63 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
|
|||
|
||||
/// Trigger a focus flash animation for this panel.
|
||||
func triggerFlash()
|
||||
|
||||
/// Capture the panel-local focus target that should be restored later.
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
|
||||
|
||||
/// Return the best focus target to restore when this panel becomes active again.
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent
|
||||
|
||||
/// Prime panel-local focus state before activation side effects run.
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent)
|
||||
|
||||
/// Restore a previously captured focus target.
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool
|
||||
|
||||
/// Return the semantic focus target currently owned by this panel, if any.
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent?
|
||||
|
||||
/// Explicitly yield a previously owned focus target before another panel restores focus.
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool
|
||||
}
|
||||
|
||||
/// Extension providing default implementations
|
||||
extension Panel {
|
||||
public var displayIcon: String? { nil }
|
||||
public var isDirty: Bool { false }
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
_ = window
|
||||
return preferredFocusIntentForActivation()
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
.panel
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
_ = intent
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
guard intent == .panel else { return false }
|
||||
focus()
|
||||
return true
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
_ = responder
|
||||
_ = window
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
_ = intent
|
||||
_ = window
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,4 +222,42 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
func applyWindowBackgroundIfActive() {
|
||||
surface.applyWindowBackgroundIfActive()
|
||||
}
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
.terminal(hostedView.capturePanelFocusIntent(in: window))
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
.terminal(hostedView.preferredPanelFocusIntentForActivation())
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
guard case .terminal(let target) = intent else { return }
|
||||
hostedView.preparePanelFocusIntentForActivation(target)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .panel:
|
||||
focus()
|
||||
return true
|
||||
case .terminal(let target):
|
||||
return hostedView.restorePanelFocusIntent(target)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
_ = window
|
||||
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
|
||||
return .terminal(intent)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard case .terminal(let target) = intent else { return false }
|
||||
return hostedView.yieldPanelFocusIntent(target, in: window)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,6 +558,11 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
|||
|
||||
@MainActor
|
||||
class TabManager: ObservableObject {
|
||||
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
|
||||
let branch: String?
|
||||
let isDirty: Bool
|
||||
}
|
||||
|
||||
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
|
||||
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
|
||||
weak var window: NSWindow?
|
||||
|
|
@ -569,6 +574,7 @@ class TabManager: ObservableObject {
|
|||
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
|
||||
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
|
||||
private static var nextPortOrdinal: Int = 0
|
||||
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
|
||||
@Published var selectedTabId: UUID? {
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
|
|
@ -624,6 +630,12 @@ class TabManager: ObservableObject {
|
|||
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
|
||||
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
|
||||
private let initialWorkspaceGitProbeQueue = DispatchQueue(
|
||||
label: "com.cmux.initial-workspace-git-probe",
|
||||
qos: .utility
|
||||
)
|
||||
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
|
||||
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
|
||||
|
||||
// Recent tab history for back/forward navigation (like browser history)
|
||||
private var tabHistory: [UUID] = []
|
||||
|
|
@ -771,7 +783,8 @@ class TabManager: ObservableObject {
|
|||
initialTerminalEnvironment: [String: String] = [:],
|
||||
select: Bool = true,
|
||||
eagerLoadTerminal: Bool = false,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
placementOverride: NewWorkspacePlacement? = nil,
|
||||
autoWelcomeIfNeeded: Bool = true
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
|
|
@ -811,9 +824,209 @@ class TabManager: ObservableObject {
|
|||
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
|
||||
])
|
||||
#endif
|
||||
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true)
|
||||
} else {
|
||||
sendWelcomeWhenReady(to: newWorkspace)
|
||||
}
|
||||
}
|
||||
return newWorkspace
|
||||
}
|
||||
|
||||
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = workspace.focusedTerminalPanel,
|
||||
terminalPanel.surface.surface != nil {
|
||||
// Wait a bit more for the shell prompt to be ready
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
terminalPanel.sendText("cmux welcome\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
directory: String
|
||||
) {
|
||||
let normalizedDirectory = normalizeDirectory(directory)
|
||||
let generation = UUID()
|
||||
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
|
||||
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
|
||||
)
|
||||
#endif
|
||||
|
||||
let delays = Self.initialWorkspaceGitProbeDelays
|
||||
var timers: [DispatchSourceTimer] = []
|
||||
for (index, delay) in delays.enumerated() {
|
||||
let isLastAttempt = index == delays.count - 1
|
||||
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
|
||||
timer.schedule(deadline: .now() + delay, repeating: .never)
|
||||
timer.setEventHandler { [weak self] in
|
||||
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.applyInitialWorkspaceGitMetadataSnapshot(
|
||||
snapshot,
|
||||
generation: generation,
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
expectedDirectory: normalizedDirectory,
|
||||
isLastAttempt: isLastAttempt
|
||||
)
|
||||
}
|
||||
}
|
||||
timers.append(timer)
|
||||
timer.resume()
|
||||
}
|
||||
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
|
||||
}
|
||||
|
||||
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
|
||||
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
|
||||
return
|
||||
}
|
||||
for timer in timers {
|
||||
timer.setEventHandler {}
|
||||
timer.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
|
||||
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
|
||||
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func applyInitialWorkspaceGitMetadataSnapshot(
|
||||
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
|
||||
generation: UUID,
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
expectedDirectory: String,
|
||||
isLastAttempt: Bool
|
||||
) {
|
||||
defer {
|
||||
if isLastAttempt,
|
||||
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
|
||||
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
return
|
||||
}
|
||||
guard workspace.panels[panelId] != nil else {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
let currentDirectory = normalizedWorkingDirectory(
|
||||
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
|
||||
)
|
||||
if let currentDirectory, currentDirectory != expectedDirectory {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
|
||||
"expected=\(expectedDirectory) current=\(currentDirectory)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
|
||||
|
||||
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
|
||||
let nextBranch = snapshot.branch
|
||||
if let nextBranch {
|
||||
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
|
||||
} else {
|
||||
workspace.clearPanelGitBranch(panelId: panelId)
|
||||
}
|
||||
|
||||
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
|
||||
workspace.clearPanelPullRequest(panelId: panelId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let branchLabel = snapshot.branch ?? "none"
|
||||
dlog(
|
||||
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
|
||||
for directory: String
|
||||
) -> InitialWorkspaceGitMetadataSnapshot {
|
||||
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
|
||||
guard let branch else {
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
|
||||
}
|
||||
|
||||
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
|
||||
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
|
||||
}
|
||||
|
||||
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
|
||||
let process = Process()
|
||||
let stdout = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["git", "-C", directory] + arguments
|
||||
process.standardOutput = stdout
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
|
||||
let data = stdout.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
|
||||
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
|
||||
}
|
||||
|
||||
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
|
||||
}
|
||||
|
||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
||||
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
}
|
||||
|
||||
// Keep addTab as convenience alias
|
||||
@discardableResult
|
||||
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
|
||||
|
|
@ -1000,20 +1213,6 @@ class TabManager: ObservableObject {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
_ = pendingBackgroundWorkspaceLoadIds.insert(workspaceId)
|
||||
}
|
||||
|
||||
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
pendingBackgroundWorkspaceLoadIds.remove(workspaceId)
|
||||
}
|
||||
|
||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
||||
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
}
|
||||
|
||||
func closeWorkspace(_ workspace: Workspace) {
|
||||
guard tabs.count > 1 else { return }
|
||||
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
||||
|
|
|
|||
|
|
@ -1742,6 +1742,16 @@ class TerminalController {
|
|||
case "workspace.remote.status":
|
||||
return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params))
|
||||
|
||||
// Settings
|
||||
case "settings.open":
|
||||
return v2Result(id: id, self.v2SettingsOpen(params: params))
|
||||
|
||||
// Feedback
|
||||
case "feedback.open":
|
||||
return v2Result(id: id, self.v2FeedbackOpen(params: params))
|
||||
case "feedback.submit":
|
||||
return v2Result(id: id, self.v2FeedbackSubmit(params: params))
|
||||
|
||||
|
||||
// Surfaces / input
|
||||
case "surface.list":
|
||||
|
|
@ -2093,6 +2103,9 @@ class TerminalController {
|
|||
"workspace.remote.reconnect",
|
||||
"workspace.remote.disconnect",
|
||||
"workspace.remote.status",
|
||||
"settings.open",
|
||||
"feedback.open",
|
||||
"feedback.submit",
|
||||
"surface.list",
|
||||
"surface.current",
|
||||
"surface.focus",
|
||||
|
|
@ -3956,6 +3969,9 @@ class TerminalController {
|
|||
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
|
||||
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
|
||||
]
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible()
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
|
|
@ -5608,6 +5624,109 @@ class TerminalController {
|
|||
return .ok([:])
|
||||
}
|
||||
|
||||
private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult {
|
||||
let workspaceId = v2UUID(params, "workspace_id")
|
||||
let windowId = v2UUID(params, "window_id")
|
||||
let shouldActivate = v2Bool(params, "activate") ?? false
|
||||
DispatchQueue.main.async {
|
||||
let targetWindow: NSWindow?
|
||||
if let windowId, let app = AppDelegate.shared {
|
||||
targetWindow = app.mainWindow(for: windowId)
|
||||
} else if let workspaceId, let app = AppDelegate.shared {
|
||||
targetWindow = app.mainWindowContainingWorkspace(workspaceId)
|
||||
} else {
|
||||
targetWindow = nil
|
||||
}
|
||||
|
||||
if shouldActivate {
|
||||
if let targetWindow {
|
||||
targetWindow.makeKeyAndOrderFront(nil)
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
} else {
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackComposerBridge.openComposer(in: targetWindow)
|
||||
}
|
||||
return .ok(["opened": true])
|
||||
}
|
||||
|
||||
private func v2SettingsOpen(params: [String: Any]) -> V2CallResult {
|
||||
let targetRaw = v2String(params, "target")
|
||||
let shouldActivate = v2Bool(params, "activate") ?? true
|
||||
|
||||
let navigationTarget: SettingsNavigationTarget?
|
||||
switch targetRaw {
|
||||
case nil:
|
||||
navigationTarget = nil
|
||||
case SettingsNavigationTarget.keyboardShortcuts.rawValue:
|
||||
navigationTarget = .keyboardShortcuts
|
||||
default:
|
||||
return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""])
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if shouldActivate {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget)
|
||||
} else {
|
||||
SettingsWindowController.shared.show(navigationTarget: navigationTarget)
|
||||
}
|
||||
}
|
||||
return .ok([
|
||||
"opened": true,
|
||||
"target": navigationTarget?.rawValue ?? "general",
|
||||
])
|
||||
}
|
||||
|
||||
private func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult {
|
||||
guard let email = params["email"] as? String else {
|
||||
return .err(code: "invalid_params", message: "Missing email", data: ["field": "email"])
|
||||
}
|
||||
guard let body = params["body"] as? String else {
|
||||
return .err(code: "invalid_params", message: "Missing body", data: ["field": "body"])
|
||||
}
|
||||
let imagePaths = params["image_paths"] as? [String] ?? []
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Feedback submission failed", data: nil)
|
||||
|
||||
Task {
|
||||
let resolved: V2CallResult
|
||||
do {
|
||||
let attachmentCount = try await FeedbackComposerBridge.submit(
|
||||
email: email,
|
||||
message: body,
|
||||
imagePaths: imagePaths
|
||||
)
|
||||
resolved = .ok([
|
||||
"submitted": true,
|
||||
"attachment_count": attachmentCount,
|
||||
])
|
||||
} catch let error as FeedbackComposerBridgeError {
|
||||
let code: String
|
||||
switch error {
|
||||
case .invalidEmail, .emptyMessage, .messageTooLong, .tooManyImages, .invalidImagePath:
|
||||
code = "invalid_params"
|
||||
case .submissionFailed:
|
||||
code = "request_failed"
|
||||
}
|
||||
resolved = .err(code: code, message: error.localizedDescription, data: nil)
|
||||
} catch {
|
||||
resolved = .err(code: "internal_error", message: error.localizedDescription, data: nil)
|
||||
}
|
||||
|
||||
result = resolved
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
if semaphore.wait(timeout: .now() + 35) == .timedOut {
|
||||
return .err(code: "timeout", message: "Feedback submission timed out", data: nil)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - V2 App Focus Methods
|
||||
|
||||
private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
|
||||
private struct NotificationsPopoverView: View {
|
||||
@ObservedObject var notificationStore: TerminalNotificationStore
|
||||
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -909,12 +910,28 @@ private struct NotificationsPopoverView: View {
|
|||
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if !notificationStore.notifications.isEmpty {
|
||||
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
|
||||
notificationStore.clearAll()
|
||||
Button(action: jumpToLatestUnread) {
|
||||
HStack(spacing: 6) {
|
||||
Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest"))
|
||||
Text(jumpToUnreadShortcut.displayString)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("notificationsPopover.jumpToLatest")
|
||||
.accessibilityValue(jumpToUnreadShortcut.displayString)
|
||||
.safeHelp(
|
||||
KeyboardShortcutSettings.Action.jumpToUnread.tooltip(
|
||||
String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")
|
||||
)
|
||||
)
|
||||
.disabled(!hasUnreadNotifications)
|
||||
|
||||
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
|
||||
notificationStore.clearAll()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("notificationsPopover.clearAll")
|
||||
.disabled(notificationStore.notifications.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
|
|
@ -957,6 +974,32 @@ private struct NotificationsPopoverView: View {
|
|||
AppDelegate.shared?.tabTitle(for: tabId)
|
||||
}
|
||||
|
||||
private var jumpToUnreadShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: jumpToUnreadShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var hasUnreadNotifications: Bool {
|
||||
notificationStore.notifications.contains(where: { !$0.isRead })
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
return fallback
|
||||
}
|
||||
return shortcut
|
||||
}
|
||||
|
||||
private func jumpToLatestUnread() {
|
||||
DispatchQueue.main.async {
|
||||
AppDelegate.shared?.jumpToLatestUnread()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ notification: TerminalNotification) {
|
||||
// SwiftUI action closures are not guaranteed to run on the main actor.
|
||||
// Ensure window focus + tab selection happens on the main thread.
|
||||
|
|
|
|||
|
|
@ -3555,7 +3555,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
|
||||
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
|
||||
private var isApplyingTabSelection = false
|
||||
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
|
||||
private struct PendingTabSelectionRequest {
|
||||
let tabId: TabID
|
||||
let pane: PaneID
|
||||
let reassertAppKitFocus: Bool
|
||||
let focusIntent: PanelFocusIntent?
|
||||
let previousTerminalHostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
private var pendingTabSelection: PendingTabSelectionRequest?
|
||||
private var isReconcilingFocusState = false
|
||||
private var focusReconcileScheduled = false
|
||||
#if DEBUG
|
||||
|
|
@ -5604,6 +5611,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}()
|
||||
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
|
||||
#if DEBUG
|
||||
let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let selectedTabShort = bonsplitController.focusedPaneId
|
||||
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
|
||||
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
|
||||
let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.panel.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " +
|
||||
"targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " +
|
||||
"converged=\(selectionAlreadyConverged ? 1 : 0) " +
|
||||
"currentPanel=\(currentPanelShort)"
|
||||
)
|
||||
if shouldSuppressReentrantRefocus {
|
||||
dlog(
|
||||
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
|
||||
|
|
@ -5613,34 +5633,65 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
#endif
|
||||
|
||||
if let targetPaneId, !selectionAlreadyConverged {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
bonsplitController.focusPane(targetPaneId)
|
||||
}
|
||||
|
||||
if !selectionAlreadyConverged {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
bonsplitController.selectTab(tabId)
|
||||
}
|
||||
|
||||
// Also focus the underlying panel
|
||||
if let panel = panels[panelId] {
|
||||
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
|
||||
panel.focus()
|
||||
}
|
||||
|
||||
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
|
||||
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
|
||||
// (becomeFirstResponder -> onFocus -> focusPanel).
|
||||
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let targetPaneId, !shouldSuppressReentrantRefocus {
|
||||
applyTabSelection(tabId: tabId, inPane: targetPaneId)
|
||||
if let targetPaneId {
|
||||
let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
|
||||
applyTabSelection(
|
||||
tabId: tabId,
|
||||
inPane: targetPaneId,
|
||||
reassertAppKitFocus: !shouldSuppressReentrantRefocus,
|
||||
focusIntent: activationIntent,
|
||||
previousTerminalHostedView: previousTerminalHostedView
|
||||
)
|
||||
}
|
||||
|
||||
if let browserPanel = panels[panelId] as? BrowserPanel {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
|
||||
}
|
||||
|
||||
if trigger == .terminalFirstResponder,
|
||||
panels[panelId] is TerminalPanel {
|
||||
scheduleTerminalFirstResponderReassert(panelId: panelId)
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal click can arrive while AppKit and bonsplit already look converged, which takes
|
||||
/// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus
|
||||
/// on the next couple of turns so stale callbacks from split churn can't leave keyboard input
|
||||
/// attached to the wrong surface (#1147).
|
||||
private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.focusedPanelId == panelId,
|
||||
let terminalPanel = self.terminalPanel(for: panelId) else {
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId)
|
||||
self.scheduleTerminalFirstResponderReassert(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
|
||||
|
|
@ -5753,9 +5804,28 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
@discardableResult
|
||||
func toggleSplitZoom(panelId: UUID) -> Bool {
|
||||
let wasSplitZoomed = bonsplitController.isSplitZoomed
|
||||
guard let paneId = paneId(forPanelId: panelId) else { return false }
|
||||
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
|
||||
focusPanel(panelId)
|
||||
reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
|
||||
reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom")
|
||||
scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4)
|
||||
scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: 4,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
scheduleTerminalGeometryReconcile()
|
||||
if let browserPanel = browserPanel(for: panelId) {
|
||||
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
|
||||
inPane: paneId,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4)
|
||||
if wasSplitZoomed && !bonsplitController.isSplitZoomed {
|
||||
scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -6012,6 +6082,248 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> {
|
||||
let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds
|
||||
var visiblePanelIds: Set<UUID> = []
|
||||
|
||||
for paneId in renderedPaneIds {
|
||||
let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first
|
||||
guard let selectedTab,
|
||||
let panelId = panelIdFromSurfaceId(selectedTab.id),
|
||||
panels[panelId] != nil else {
|
||||
continue
|
||||
}
|
||||
visiblePanelIds.insert(panelId)
|
||||
}
|
||||
|
||||
if let focusedPanelId,
|
||||
panels[focusedPanelId] != nil,
|
||||
let focusedPaneId = paneId(forPanelId: focusedPanelId),
|
||||
renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) {
|
||||
visiblePanelIds.insert(focusedPanelId)
|
||||
}
|
||||
|
||||
return visiblePanelIds
|
||||
}
|
||||
|
||||
private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
|
||||
terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
|
||||
terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id)
|
||||
TerminalWindowPortalRegistry.updateEntryVisibility(
|
||||
for: terminalPanel.hostedView,
|
||||
visibleInUI: shouldBeVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func terminalPortalVisibilityNeedsFollowUp() -> Bool {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
|
||||
let hostedView = terminalPanel.hostedView
|
||||
|
||||
if shouldBeVisible {
|
||||
if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil {
|
||||
return true
|
||||
}
|
||||
} else if !hostedView.isHidden {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
|
||||
|
||||
if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
|
||||
self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(browserPanel.id)
|
||||
if shouldBeVisible {
|
||||
BrowserWindowPortalRegistry.updateEntryVisibility(
|
||||
for: browserPanel.webView,
|
||||
visibleInUI: true,
|
||||
zPriority: 2
|
||||
)
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
if anchorReady {
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
|
||||
BrowserWindowPortalRegistry.refresh(
|
||||
webView: browserPanel.webView,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BrowserWindowPortalRegistry.updateEntryVisibility(
|
||||
for: browserPanel.webView,
|
||||
visibleInUI: false,
|
||||
zPriority: 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: browserPanel.webView,
|
||||
source: reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func browserPortalVisibilityNeedsFollowUp() -> Bool {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
guard visiblePanelIds.contains(browserPanel.id) else { continue }
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
if !anchorReady ||
|
||||
browserPanel.webView.window == nil ||
|
||||
browserPanel.webView.superview == nil ||
|
||||
!BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: Int,
|
||||
reason: String
|
||||
) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
|
||||
|
||||
if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
|
||||
self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: remainingPasses - 1,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browser panes host WKWebView in the window portal. After pane zoom toggles,
|
||||
// force a few post-layout sync passes so the portal does not outlive the omnibar chrome.
|
||||
private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let browserPanel = self.browserPanel(for: panelId) else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
|
||||
if anchorReady {
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
|
||||
BrowserWindowPortalRegistry.refresh(
|
||||
webView: browserPanel.webView,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
}
|
||||
|
||||
let portalNeedsFollowUpPass =
|
||||
!anchorReady ||
|
||||
browserPanel.webView.window == nil ||
|
||||
browserPanel.webView.superview == nil
|
||||
if portalNeedsFollowUpPass {
|
||||
self.scheduleBrowserPortalReconcileAfterSplitZoom(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is
|
||||
// still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles
|
||||
// so the SwiftUI chrome does not remain hidden until another browser focus command runs.
|
||||
private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.browserPanel(for: panelId) != nil else { return }
|
||||
guard let paneId = self.paneId(forPanelId: panelId),
|
||||
let tabId = self.surfaceIdFromPanelId(panelId) else { return }
|
||||
|
||||
let selectionConverged =
|
||||
self.bonsplitController.focusedPaneId == paneId &&
|
||||
self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId
|
||||
let anchorReady: Bool = {
|
||||
guard let browserPanel = self.browserPanel(for: panelId) else { return false }
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
return
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
}()
|
||||
|
||||
if !selectionConverged {
|
||||
self.focusPanel(panelId)
|
||||
self.scheduleFocusReconcile()
|
||||
}
|
||||
|
||||
if !selectionConverged || !anchorReady {
|
||||
self.scheduleBrowserSplitZoomExitFocusReassert(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleMovedTerminalRefresh(panelId: UUID) {
|
||||
guard terminalPanel(for: panelId) != nil else { return }
|
||||
|
||||
|
|
@ -6280,8 +6592,20 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
|
||||
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
|
||||
private func applyTabSelection(tabId: TabID, inPane pane: PaneID) {
|
||||
pendingTabSelection = (tabId: tabId, pane: pane)
|
||||
private func applyTabSelection(
|
||||
tabId: TabID,
|
||||
inPane pane: PaneID,
|
||||
reassertAppKitFocus: Bool = true,
|
||||
focusIntent: PanelFocusIntent? = nil,
|
||||
previousTerminalHostedView: GhosttySurfaceScrollView? = nil
|
||||
) {
|
||||
pendingTabSelection = PendingTabSelectionRequest(
|
||||
tabId: tabId,
|
||||
pane: pane,
|
||||
reassertAppKitFocus: reassertAppKitFocus,
|
||||
focusIntent: focusIntent,
|
||||
previousTerminalHostedView: previousTerminalHostedView
|
||||
)
|
||||
guard !isApplyingTabSelection else { return }
|
||||
isApplyingTabSelection = true
|
||||
defer {
|
||||
|
|
@ -6294,12 +6618,36 @@ extension Workspace: BonsplitDelegate {
|
|||
pendingTabSelection = nil
|
||||
iterations += 1
|
||||
if iterations > 8 { break }
|
||||
applyTabSelectionNow(tabId: request.tabId, inPane: request.pane)
|
||||
applyTabSelectionNow(
|
||||
tabId: request.tabId,
|
||||
inPane: request.pane,
|
||||
reassertAppKitFocus: request.reassertAppKitFocus,
|
||||
focusIntent: request.focusIntent,
|
||||
previousTerminalHostedView: request.previousTerminalHostedView
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
|
||||
private func applyTabSelectionNow(
|
||||
tabId: TabID,
|
||||
inPane pane: PaneID,
|
||||
reassertAppKitFocus: Bool,
|
||||
focusIntent: PanelFocusIntent?,
|
||||
previousTerminalHostedView: GhosttySurfaceScrollView?
|
||||
) {
|
||||
let previousFocusedPanelId = focusedPanelId
|
||||
#if DEBUG
|
||||
let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let selectedTabBefore = bonsplitController.focusedPaneId
|
||||
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
|
||||
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " +
|
||||
"focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " +
|
||||
"reassert=\(reassertAppKitFocus ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if bonsplitController.allPaneIds.contains(pane) {
|
||||
if bonsplitController.focusedPaneId != pane {
|
||||
bonsplitController.focusPane(pane)
|
||||
|
|
@ -6334,6 +6682,8 @@ extension Workspace: BonsplitDelegate {
|
|||
if shouldTreatCurrentEventAsExplicitFocusIntent() {
|
||||
markExplicitFocusIntent(on: panelId)
|
||||
}
|
||||
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
|
||||
panel.prepareFocusIntentForActivation(activationIntent)
|
||||
|
||||
syncPinnedStateForTab(selectedTabId, panelId: panelId)
|
||||
syncUnreadBadgeStateForPanel(panelId)
|
||||
|
|
@ -6343,11 +6693,24 @@ extension Workspace: BonsplitDelegate {
|
|||
p.unfocus()
|
||||
}
|
||||
|
||||
panel.focus()
|
||||
if let focusWindow = activationWindow(for: panel) {
|
||||
yieldForeignOwnedFocusIfNeeded(
|
||||
in: focusWindow,
|
||||
targetPanelId: panelId,
|
||||
targetIntent: activationIntent
|
||||
)
|
||||
}
|
||||
|
||||
activatePanel(
|
||||
panel,
|
||||
focusIntent: activationIntent,
|
||||
reassertAppKitFocus: reassertAppKitFocus
|
||||
)
|
||||
let focusIntentAllowsBrowserOmnibarAutofocus =
|
||||
shouldTreatCurrentEventAsExplicitFocusIntent() ||
|
||||
TerminalController.socketCommandAllowsInAppFocusMutations()
|
||||
if let browserPanel = panel as? BrowserPanel,
|
||||
shouldAllowBrowserOmnibarAutofocus(for: activationIntent),
|
||||
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
|
||||
}
|
||||
|
|
@ -6375,10 +6738,33 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
|
||||
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel {
|
||||
if shouldMoveTerminalSurfaceFocus(for: activationIntent),
|
||||
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
#if DEBUG
|
||||
let previousExists = previousTerminalHostedView != nil ? 1 : 0
|
||||
dlog(
|
||||
"focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " +
|
||||
"to=\(panelId.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " +
|
||||
"tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))"
|
||||
)
|
||||
#endif
|
||||
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
|
||||
}
|
||||
|
||||
if shouldRestoreFocusIntentAfterActivation(activationIntent) {
|
||||
_ = panel.restoreFocusIntent(activationIntent)
|
||||
}
|
||||
|
||||
// Update current directory if this is a terminal
|
||||
if let dir = panelDirectories[panelId] {
|
||||
currentDirectory = dir
|
||||
|
|
@ -6395,6 +6781,108 @@ extension Workspace: BonsplitDelegate {
|
|||
GhosttyNotificationKey.surfaceId: panelId
|
||||
]
|
||||
)
|
||||
#if DEBUG
|
||||
let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " +
|
||||
"focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " +
|
||||
"prevPanel=\(prevPanelShort)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func activatePanel(
|
||||
_ panel: any Panel,
|
||||
focusIntent: PanelFocusIntent,
|
||||
reassertAppKitFocus: Bool
|
||||
) {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
|
||||
terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
|
||||
terminalPanel.hostedView.setActive(true)
|
||||
if reassertAppKitFocus && shouldFocusTerminalSurface {
|
||||
terminalPanel.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
guard shouldFocusBrowserWebView(for: focusIntent) else { return }
|
||||
browserPanel.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if reassertAppKitFocus {
|
||||
panel.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private func activationWindow(for panel: any Panel) -> NSWindow? {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
return NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
|
||||
private func yieldForeignOwnedFocusIfNeeded(
|
||||
in window: NSWindow,
|
||||
targetPanelId: UUID,
|
||||
targetIntent: PanelFocusIntent
|
||||
) {
|
||||
guard let firstResponder = window.firstResponder else { return }
|
||||
|
||||
for (panelId, panel) in panels where panelId != targetPanelId {
|
||||
guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " +
|
||||
"fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))"
|
||||
)
|
||||
#endif
|
||||
_ = panel.yieldFocusIntent(ownedIntent, in: window)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .terminal(.findField):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.addressBar), .browser(.findField):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.webView), .panel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.addressBar), .browser(.findField), .terminal(.findField):
|
||||
return true
|
||||
case .panel, .browser(.webView), .terminal(.surface):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNonFocusSplitFocusReassert(
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ struct WorkspaceContentView: View {
|
|||
workspace.bonsplitController.focusPane(paneId)
|
||||
}
|
||||
}
|
||||
.internalOnlyTabDrag()
|
||||
// Split zoom swaps Bonsplit between the full split tree and a single pane view.
|
||||
// Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome
|
||||
// cannot remain stacked above portal-hosted browser content.
|
||||
.id(splitZoomRenderIdentity)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
syncBonsplitNotificationBadges()
|
||||
|
|
@ -174,6 +179,10 @@ struct WorkspaceContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var splitZoomRenderIdentity: String {
|
||||
workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed"
|
||||
}
|
||||
|
||||
static func resolveGhosttyAppearanceConfig(
|
||||
reason: String = "unspecified",
|
||||
backgroundOverride: NSColor? = nil,
|
||||
|
|
|
|||
|
|
@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum WelcomeSettings {
|
||||
static let shownKey = "cmuxWelcomeShown"
|
||||
}
|
||||
|
||||
enum TelemetrySettings {
|
||||
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
|
||||
static let defaultSendAnonymousTelemetry = true
|
||||
|
|
@ -2833,6 +2837,7 @@ enum TelemetrySettings {
|
|||
struct SettingsView: View {
|
||||
private let contentTopInset: CGFloat = 8
|
||||
private let pickerColumnWidth: CGFloat = 196
|
||||
private let notificationSoundControlWidth: CGFloat = 280
|
||||
|
||||
@AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue
|
||||
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
|
|
@ -3321,7 +3326,8 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
|
||||
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives.")
|
||||
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."),
|
||||
controlWidth: notificationSoundControlWidth
|
||||
) {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
|
|
@ -3381,6 +3387,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue