Ensure tab focus owns key input

This commit is contained in:
Lawrence Chen 2026-01-28 03:12:50 -08:00
parent 57ae8d9c0c
commit ca9c680da7
4 changed files with 157 additions and 0 deletions

View file

@ -28,6 +28,7 @@
A5001206 /* UpdateBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001216 /* UpdateBadge.swift */; };
A500120A /* UpdateTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001220 /* UpdateTiming.swift */; };
A500120B /* UpdateTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001221 /* UpdateTestSupport.swift */; };
A500120E /* UpdateTestURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001224 /* UpdateTestURLProtocol.swift */; };
A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; };
A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; };
A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; };
@ -89,6 +90,7 @@
A5001216 /* UpdateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateBadge.swift; sourceTree = "<group>"; };
A5001220 /* UpdateTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTiming.swift; sourceTree = "<group>"; };
A5001221 /* UpdateTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTestSupport.swift; sourceTree = "<group>"; };
A5001224 /* UpdateTestURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTestURLProtocol.swift; sourceTree = "<group>"; };
A5001217 /* UpdatePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePopoverView.swift; sourceTree = "<group>"; };
A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = "<group>"; };
A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = "<group>"; };
@ -176,6 +178,7 @@
A5001216 /* UpdateBadge.swift */,
A5001220 /* UpdateTiming.swift */,
A5001221 /* UpdateTestSupport.swift */,
A5001224 /* UpdateTestURLProtocol.swift */,
A5001223 /* UpdateLogStore.swift */,
A5001217 /* UpdatePopoverView.swift */,
A5001218 /* UpdateTitlebarAccessory.swift */,
@ -310,6 +313,7 @@
A5001206 /* UpdateBadge.swift in Sources */,
A500120A /* UpdateTiming.swift in Sources */,
A500120B /* UpdateTestSupport.swift in Sources */,
A500120E /* UpdateTestURLProtocol.swift in Sources */,
A500120D /* UpdateLogStore.swift in Sources */,
A5001207 /* UpdatePopoverView.swift in Sources */,
A5001208 /* UpdateTitlebarAccessory.swift in Sources */,

View file

@ -1771,6 +1771,41 @@ final class GhosttySurfaceScrollView: NSView {
}
}
func ensureFocus(for tabId: UUID, surfaceId: UUID, attempt: Int = 0) {
let maxAttempts = 6
guard attempt < maxAttempts else { return }
guard let tabManager = AppDelegate.shared?.tabManager,
tabManager.selectedTabId == tabId,
tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
guard let window else {
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
return
}
guard window.isKeyWindow else {
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
return
}
if window.firstResponder === surfaceView {
return
}
window.makeFirstResponder(surfaceView)
if window.firstResponder !== surfaceView {
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
}
}
private func scheduleFocusRetry(for tabId: UUID, surfaceId: UUID, attempt: Int) {
let delay = 0.05 * pow(2.0, Double(attempt))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attempt: attempt + 1)
}
}
private func updateFocusForWindow() {
let shouldFocus = isActive && (window?.isKeyWindow ?? false)
surfaceView.desiredFocus = shouldFocus

View file

@ -395,6 +395,7 @@ class TabManager: ObservableObject {
tabs.first(where: { $0.id == id })?.focusedSurface
}
surface.hostedView.moveFocus(from: previousSurface?.hostedView)
surface.hostedView.ensureFocus(for: selectedTabId, surfaceId: surface.id)
}
private func updateTabTitle(tabId: UUID, title: String) {

View file

@ -0,0 +1,117 @@
#if DEBUG
import Foundation
final class UpdateTestURLProtocol: URLProtocol {
static let host = "cmuxterm.test"
static let appcastPath = "/appcast.xml"
static let updatePath = "/cmuxterm-test.zip"
private static var isRegistered = false
private static let registrationLock = NSLock()
static func registerIfNeeded() {
registrationLock.lock()
defer { registrationLock.unlock() }
guard !isRegistered else { return }
URLProtocol.registerClass(UpdateTestURLProtocol.self)
isRegistered = true
}
override class func canInit(with request: URLRequest) -> Bool {
guard let url = request.url else { return false }
guard url.host == host else { return false }
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let url = request.url else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
let method = request.httpMethod?.uppercased() ?? "GET"
let (statusCode, data, contentType) = payload(for: url)
let headers = [
"Content-Type": contentType,
"Content-Length": "\(data.count)"
]
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
if method != "HEAD" {
client?.urlProtocol(self, didLoad: data)
}
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
private func payload(for url: URL) -> (Int, Data, String) {
switch url.path {
case Self.appcastPath:
let data = Self.appcastData()
return (200, data, "application/xml")
case Self.updatePath:
let data = Self.updateArchiveData()
return (200, data, "application/octet-stream")
default:
let data = Data("Not Found".utf8)
return (404, data, "text/plain")
}
}
private static func appcastData() -> Data {
let env = ProcessInfo.processInfo.environment
let mode = env["CMUX_UI_TEST_FEED_MODE"] ?? "available"
let version = env["CMUX_UI_TEST_UPDATE_VERSION"] ?? "9.9.9"
let updateURL = "https://\(host)\(updatePath)"
let updateLength = updateArchiveData().count
let item: String
if mode == "none" {
item = ""
} else {
item = """
<item>
<title>cmuxterm \(version)</title>
<sparkle:version>\(version)</sparkle:version>
<sparkle:shortVersionString>\(version)</sparkle:shortVersionString>
<enclosure url="\(updateURL)" length="\(updateLength)" type="application/octet-stream" />
</item>
"""
}
let xml = """
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>cmuxterm Test Updates</title>
<link>https://\(host)</link>
<description>Test updates feed</description>
<language>en</language>
\(item)
</channel>
</rss>
"""
return Data(xml.utf8)
}
private static func updateArchiveData() -> Data {
Data("cmuxterm test update".utf8)
}
}
#endif