Show remote terminal title in menu bar
This commit is contained in:
1 parent
82ea7c35d0
commit
b2b87696a9
8 files changed
+430
-1
No files matched your search
@@ -0,0 +1,205 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// A terminal title is authored by the remote host. Keep it on one printable
|
||||
/// line before allowing it into AppKit's global menu structure.
|
||||
nonisolated enum RemoteWindowTitle {
|
||||
static func sanitized(_ proposed: String) -> String? {
|
||||
enum EscapeState { case text, escape, controlSequence }
|
||||
|
||||
var state = EscapeState.text
|
||||
var result = ""
|
||||
var insertsSeparator = false
|
||||
|
||||
for scalar in proposed.unicodeScalars {
|
||||
switch state {
|
||||
case .escape:
|
||||
state = scalar.value == 0x5B ? .controlSequence : .text
|
||||
case .controlSequence:
|
||||
// ECMA-48 CSI sequences end at the first final byte.
|
||||
if (0x40...0x7E).contains(scalar.value) { state = .text }
|
||||
case .text:
|
||||
if scalar.value == 0x1B {
|
||||
state = .escape
|
||||
} else if scalar.value == 0x9B {
|
||||
state = .controlSequence
|
||||
} else if CharacterSet.controlCharacters.contains(scalar)
|
||||
|| CharacterSet.newlines.contains(scalar)
|
||||
{
|
||||
insertsSeparator = !result.isEmpty
|
||||
} else {
|
||||
if insertsSeparator, !CharacterSet.whitespaces.contains(scalar) {
|
||||
result.append(" ")
|
||||
}
|
||||
insertsSeparator = false
|
||||
result.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cleaned = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return cleaned.isEmpty ? nil : cleaned
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors Kitty's macOS title item: an empty top-level item whose empty
|
||||
/// submenu carries ` :: <active terminal title>`. Appending it to `mainMenu`
|
||||
/// places it after the ordinary Help menu without turning the title into an
|
||||
/// application command.
|
||||
@MainActor
|
||||
final class RemoteWindowTitleMenuCoordinator {
|
||||
static let shared = RemoteWindowTitleMenuCoordinator()
|
||||
|
||||
private final class Report {
|
||||
weak var window: NSWindow?
|
||||
var title: String?
|
||||
var revision: UInt64
|
||||
|
||||
init(window: NSWindow?, title: String?, revision: UInt64) {
|
||||
self.window = window
|
||||
self.title = title
|
||||
self.revision = revision
|
||||
}
|
||||
}
|
||||
|
||||
private static let itemIdentifier = NSUserInterfaceItemIdentifier(
|
||||
"com.akinokaede.termind.remote-window-title"
|
||||
)
|
||||
|
||||
private let mainMenuProvider: () -> NSMenu?
|
||||
private let keyWindowProvider: () -> NSWindow?
|
||||
private var reports: [UUID: Report] = [:]
|
||||
private var revision: UInt64 = 0
|
||||
private var keyWindowObservers: [any NSObjectProtocol] = []
|
||||
private var titleMenuItem: NSMenuItem?
|
||||
|
||||
init(
|
||||
mainMenuProvider: @escaping () -> NSMenu? = { NSApp.mainMenu },
|
||||
keyWindowProvider: @escaping () -> NSWindow? = { NSApp.keyWindow },
|
||||
observesKeyWindowChanges: Bool = true
|
||||
) {
|
||||
self.mainMenuProvider = mainMenuProvider
|
||||
self.keyWindowProvider = keyWindowProvider
|
||||
if observesKeyWindowChanges { observeKeyWindowChanges() }
|
||||
}
|
||||
|
||||
func report(title: String?, for window: NSWindow?, token: UUID) {
|
||||
revision &+= 1
|
||||
reports[token] = Report(window: window, title: title, revision: revision)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func withdraw(token: UUID) {
|
||||
reports[token] = nil
|
||||
refresh()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
reports = reports.filter { $0.value.window != nil }
|
||||
guard let keyWindow = keyWindowProvider() else {
|
||||
removeTitleMenuItem()
|
||||
return
|
||||
}
|
||||
let title = reports.values
|
||||
.filter { $0.window === keyWindow }
|
||||
.max { $0.revision < $1.revision }?
|
||||
.title
|
||||
guard let title = title.flatMap(RemoteWindowTitle.sanitized) else {
|
||||
removeTitleMenuItem()
|
||||
return
|
||||
}
|
||||
installOrUpdateTitleMenuItem(title)
|
||||
}
|
||||
|
||||
private func observeKeyWindowChanges() {
|
||||
let center = NotificationCenter.default
|
||||
for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] {
|
||||
keyWindowObservers.append(
|
||||
center.addObserver(
|
||||
forName: name, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refresh() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func installOrUpdateTitleMenuItem(_ title: String) {
|
||||
guard let mainMenu = mainMenuProvider() else { return }
|
||||
let item: NSMenuItem
|
||||
if let installed = titleMenuItem, installed.menu === mainMenu {
|
||||
item = installed
|
||||
} else if let existing = mainMenu.items.first(where: {
|
||||
$0.identifier == Self.itemIdentifier
|
||||
}) {
|
||||
titleMenuItem = existing
|
||||
item = existing
|
||||
} else {
|
||||
let created = NSMenuItem(title: "", action: nil, keyEquivalent: "")
|
||||
created.identifier = Self.itemIdentifier
|
||||
created.submenu = NSMenu()
|
||||
mainMenu.addItem(created)
|
||||
titleMenuItem = created
|
||||
item = created
|
||||
}
|
||||
item.submenu?.title = " :: \(title)"
|
||||
}
|
||||
|
||||
private func removeTitleMenuItem() {
|
||||
guard let item = titleMenuItem else { return }
|
||||
item.menu?.removeItem(item)
|
||||
titleMenuItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A dedicated observation boundary for the nested SSH session. Reading the
|
||||
/// session title here makes Observation invalidate this small reporter whenever
|
||||
/// Ghostty publishes another OSC 0/2 value, without rebuilding `RootView`.
|
||||
struct ActiveRemoteWindowTitleReporter: View {
|
||||
let sshSession: SSHSessionModel?
|
||||
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var isEnabled =
|
||||
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
|
||||
|
||||
var body: some View {
|
||||
RemoteWindowTitleMenuBridge(
|
||||
title: isEnabled ? sshSession?.menuBarWindowTitle : nil
|
||||
)
|
||||
.frame(width: 0, height: 0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemoteWindowTitleMenuBridge: NSViewRepresentable {
|
||||
let title: String?
|
||||
|
||||
@MainActor
|
||||
final class ReportingView: NSView {
|
||||
let token = UUID()
|
||||
var reportedTitle: String?
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
report()
|
||||
}
|
||||
|
||||
func report() {
|
||||
RemoteWindowTitleMenuCoordinator.shared.report(
|
||||
title: reportedTitle, for: window, token: token
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func makeNSView(context _: Context) -> ReportingView {
|
||||
let view = ReportingView(frame: .zero)
|
||||
view.reportedTitle = title
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: ReportingView, context _: Context) {
|
||||
view.reportedTitle = title
|
||||
view.report()
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ view: ReportingView, coordinator _: ()) {
|
||||
RemoteWindowTitleMenuCoordinator.shared.withdraw(token: view.token)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ struct RootView: View {
|
||||
)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
// Kitty-style macOS menu-bar title. This tiny child observes only the
|
||||
// focused SSH session, so OSC 0/2 changes do not invalidate the full
|
||||
// window hierarchy.
|
||||
.background {
|
||||
ActiveRemoteWindowTitleReporter(sshSession: window.focusedSSHSession)
|
||||
}
|
||||
.overlay { ExternalAuthWindowPresenter() }
|
||||
.frame(
|
||||
minWidth: Metrics.Window.minWidth,
|
||||
|
||||
@@ -50,6 +50,13 @@ nonisolated enum SettingsKey {
|
||||
static var isShellIntegrationEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: shellIntegration) as? Bool ?? true
|
||||
}
|
||||
/// Mirror the focused SSH terminal's OSC 0/2 title in the macOS menu bar.
|
||||
static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
|
||||
static let defaultRemoteWindowTitleInMenuBarEnabled = true
|
||||
static var isRemoteWindowTitleInMenuBarEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: remoteWindowTitleInMenuBar) as? Bool
|
||||
?? defaultRemoteWindowTitleInMenuBarEnabled
|
||||
}
|
||||
/// Restore the previous window's Workspaces on launch. Keep the persisted
|
||||
/// key stable so this UI rename does not discard the user's preference.
|
||||
static let restoreWorkspaces = "window.restoreTabs"
|
||||
|
||||
@@ -591,6 +591,8 @@ private struct TerminalSettings: View {
|
||||
@AppStorage(SettingsKey.terminalFontSize) private var fontSize = 12.0
|
||||
@AppStorage(SettingsKey.terminalFontFollowTheme) private var fontFollowTheme = true
|
||||
@AppStorage(SettingsKey.shellIntegration) private var shellIntegration = true
|
||||
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var remoteWindowTitleInMenuBar =
|
||||
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
|
||||
@AppStorage(SettingsKey.terminalThemeDark) private var darkTheme = SettingsKey.defaultTerminalThemeDark
|
||||
@AppStorage(SettingsKey.terminalThemeLight) private var lightTheme = SettingsKey.defaultTerminalThemeLight
|
||||
@AppStorage(SettingsKey.terminalCustomConfigurationDarkEnabled) private var darkCustomEnabled = false
|
||||
@@ -643,6 +645,12 @@ private struct TerminalSettings: View {
|
||||
"Keeps the remote working directory current, records confirmed commands, and provides detected distribution, kernel, and architecture details to AI features."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Show Terminal Title in Menu Bar", isOn: $remoteWindowTitleInMenuBar)
|
||||
Text(
|
||||
"Shows the focused SSH terminal's reported title after Help in the macOS menu bar."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@@ -174,6 +174,15 @@ final class SSHSessionModel: Identifiable {
|
||||
/// Whether the remote shell accepted OSC 133 integration.
|
||||
var shellIntegrationAvailable: Bool = false
|
||||
private(set) var integratedShell: String?
|
||||
/// The most recent OSC 0/2 title reported by this SSH connection.
|
||||
///
|
||||
/// Ghostty owns parsing and publishes the value through `TerminalViewState`.
|
||||
/// Keep a session-scoped copy here so reconnecting can discard a title from
|
||||
/// the old remote shell even though the long-lived terminal surface survives.
|
||||
private(set) var remoteWindowTitle: String?
|
||||
var menuBarWindowTitle: String? {
|
||||
status.isLive ? remoteWindowTitle : nil
|
||||
}
|
||||
private(set) var remoteSystemInfo: RemoteSystemInfo? {
|
||||
didSet {
|
||||
guard remoteSystemInfo != oldValue else { return }
|
||||
@@ -219,6 +228,7 @@ final class SSHSessionModel: Identifiable {
|
||||
private var hasConnectedOnce = false
|
||||
private var isRecoveringConnection = false
|
||||
private var workingDirectoryObservation: AnyCancellable?
|
||||
private var remoteWindowTitleObservation: AnyCancellable?
|
||||
/// The exact authenticated route used by the live terminal, retained only
|
||||
/// in memory so a drop can open an independent SFTP connection without
|
||||
/// asking for credentials a second time. Cleared with the SSH connection.
|
||||
@@ -293,6 +303,7 @@ final class SSHSessionModel: Identifiable {
|
||||
#endif
|
||||
relay.owner = self
|
||||
observeWorkingDirectory(from: state, through: relay)
|
||||
observeRemoteWindowTitle(from: state, through: relay)
|
||||
}
|
||||
|
||||
convenience init(host: Host, usernameHint: String? = nil, initialCommand: String? = nil) {
|
||||
@@ -343,6 +354,7 @@ final class SSHSessionModel: Identifiable {
|
||||
#endif
|
||||
relay.owner = self
|
||||
observeWorkingDirectory(from: state, through: relay)
|
||||
observeRemoteWindowTitle(from: state, through: relay)
|
||||
}
|
||||
|
||||
var agentTemporaryTarget: AgentHostTarget? {
|
||||
@@ -519,6 +531,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
private func startConnection(using store: any RemoteSessionHostStore) {
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
homeResolution = SettingsKey.isShellIntegrationEnabled ? .pending : .unavailable
|
||||
let reconnecting = hasConnectedOnce && isRecoveringConnection
|
||||
workingDirectory = nil
|
||||
@@ -925,6 +938,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
func disconnect() {
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
workingDirectory = nil
|
||||
homeResolution = .unavailable
|
||||
transferConfiguration = nil
|
||||
@@ -953,6 +967,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
func stopReconnecting() {
|
||||
guard let reconnectState else { return }
|
||||
remoteWindowTitle = nil
|
||||
reconnectController.stop()
|
||||
isRecoveringConnection = false
|
||||
connectionAttemptID = nil
|
||||
@@ -1065,6 +1080,7 @@ final class SSHSessionModel: Identifiable {
|
||||
/// Workspace instead; transport failures remain visible for reconnection.
|
||||
func handleRemoteExit(_ result: Result<Int32, TermindSSHError>) {
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
workingDirectory = nil
|
||||
homeResolution = .unavailable
|
||||
transferConfiguration = nil
|
||||
@@ -1109,6 +1125,7 @@ final class SSHSessionModel: Identifiable {
|
||||
|
||||
private func scheduleReconnect(after message: String, using store: any RemoteSessionHostStore) {
|
||||
resetCommandGeneratorShellState()
|
||||
remoteWindowTitle = nil
|
||||
isRecoveringConnection = true
|
||||
connectionAttemptID = nil
|
||||
connectionTask?.cancel()
|
||||
@@ -1293,6 +1310,21 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors Ghostty's parsed OSC 0/2 title into the SSH connection's
|
||||
/// lifecycle. `TerminalViewState.title` deliberately outlives a transport;
|
||||
/// the copy does not, preventing a reconnected shell from inheriting the
|
||||
/// previous shell or foreground program's menu-bar title.
|
||||
private func observeRemoteWindowTitle(
|
||||
from state: TerminalViewState,
|
||||
through relay: SSHSessionRelay
|
||||
) {
|
||||
remoteWindowTitleObservation = state.$title.sink { [weak relay] title in
|
||||
MainActor.assumeIsolated {
|
||||
relay?.owner?.remoteWindowTitle = RemoteWindowTitle.sanitized(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
|
||||
switch event {
|
||||
case .available(let shell, let systemInfo, let homeDirectory):
|
||||
|
||||
@@ -9751,6 +9751,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show Terminal Title in Menu Bar" : {
|
||||
"comment" : "A toggle that shows the name of the focused terminal in the macOS menu bar.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "在菜单栏中显示终端标题"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show the plan" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -9799,6 +9811,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Shows the focused SSH terminal's reported title after Help in the macOS menu bar." : {
|
||||
"comment" : "A description of the feature that shows the name of the focused terminal in the menu bar.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "在macOS菜单栏的“帮助”后显示当前SSH终端报告的标题。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sidebar" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
@@ -10005,7 +10029,15 @@
|
||||
},
|
||||
"SSH" : {
|
||||
"comment" : "Label for SSH connections.",
|
||||
"isCommentAutoGenerated" : true
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "SSH"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSH and SFTP, with an assistant at hand." : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -118,6 +118,21 @@ struct CommandHistoryTests {
|
||||
|
||||
@Suite
|
||||
struct RemoteCommandEventFilterTests {
|
||||
@Test
|
||||
func ordinaryOSCWindowTitlesRemainVisibleToGhostty() {
|
||||
var filter = RemoteCommandEventFilter(token: "TOKEN")
|
||||
let data = Data(
|
||||
"\u{1B}]0;kaede@host:/srv\u{7}\u{1B}]2;host: command\u{7}".utf8
|
||||
)
|
||||
|
||||
let update = filter.receive(data)
|
||||
|
||||
#expect(update.visibleOutput == data)
|
||||
#expect(update.commands.isEmpty)
|
||||
#expect(!update.promptReady)
|
||||
#expect(update.drafts.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func filtersChunkedEventsAndPreservesSurroundingOutput() {
|
||||
var filter = RemoteCommandEventFilter(token: "TOKEN")
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import GhosttyTerminal
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct RemoteWindowTitleTests {
|
||||
@Test
|
||||
func sshSessionMirrorsSanitizedGhosttyTitleOnlyWhileConnected() async {
|
||||
let session = SSHSessionModel(hostID: UUID(), title: "Host")
|
||||
|
||||
session.terminalState.terminalDidChangeTitle(
|
||||
"\u{1B}[31mkaede@lima-default:/Users/kaede\u{1B}[0m\nignored"
|
||||
)
|
||||
#expect(session.remoteWindowTitle == "kaede@lima-default:/Users/kaede ignored")
|
||||
#expect(session.menuBarWindowTitle == nil)
|
||||
|
||||
session.status = .connected
|
||||
#expect(session.menuBarWindowTitle == "kaede@lima-default:/Users/kaede ignored")
|
||||
|
||||
session.disconnect()
|
||||
#expect(session.remoteWindowTitle == nil)
|
||||
#expect(session.menuBarWindowTitle == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func sanitizerKeepsOnePrintableLineAndRejectsEmptyTitles() {
|
||||
#expect(RemoteWindowTitle.sanitized(" host: /srv ") == "host: /srv")
|
||||
#expect(RemoteWindowTitle.sanitized("host\r\n/srv\u{7}") == "host /srv")
|
||||
#expect(RemoteWindowTitle.sanitized("\u{1B}[1;32mgreen\u{1B}[0m") == "green")
|
||||
#expect(RemoteWindowTitle.sanitized("\n\u{7}\u{1B}[31m") == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func menuFollowsOnlyTheKeyWindowAndUsesKittyTitleFormat() throws {
|
||||
let mainMenu = NSMenu()
|
||||
let help = NSMenuItem(title: "Help", action: nil, keyEquivalent: "")
|
||||
help.submenu = NSMenu(title: "Help")
|
||||
mainMenu.addItem(help)
|
||||
let firstWindow = NSWindow()
|
||||
let secondWindow = NSWindow()
|
||||
var keyWindow: NSWindow? = firstWindow
|
||||
let coordinator = RemoteWindowTitleMenuCoordinator(
|
||||
mainMenuProvider: { mainMenu }, keyWindowProvider: { keyWindow },
|
||||
observesKeyWindowChanges: false
|
||||
)
|
||||
let firstToken = UUID()
|
||||
let secondToken = UUID()
|
||||
|
||||
coordinator.report(
|
||||
title: "kaede@lima-default:/Users/kaede",
|
||||
for: firstWindow, token: firstToken
|
||||
)
|
||||
#expect(mainMenu.items.count == 2)
|
||||
#expect(mainMenu.items[0] === help)
|
||||
#expect(mainMenu.items[1].title.isEmpty)
|
||||
#expect(
|
||||
try #require(mainMenu.items[1].submenu).title
|
||||
== " :: kaede@lima-default:/Users/kaede"
|
||||
)
|
||||
#expect(mainMenu.items[1].submenu?.items.isEmpty == true)
|
||||
|
||||
coordinator.report(title: "second:/tmp", for: secondWindow, token: secondToken)
|
||||
#expect(mainMenu.items[1].submenu?.title == " :: kaede@lima-default:/Users/kaede")
|
||||
|
||||
keyWindow = secondWindow
|
||||
coordinator.refresh()
|
||||
#expect(mainMenu.items[1].submenu?.title == " :: second:/tmp")
|
||||
|
||||
coordinator.withdraw(token: secondToken)
|
||||
#expect(mainMenu.items == [help])
|
||||
}
|
||||
|
||||
@Test(
|
||||
.disabled(
|
||||
if: !ghosttyHasActiveDisplay,
|
||||
"Ghostty's Metal surface requires an active display"
|
||||
))
|
||||
func hostManagedGhosttySurfaceParsesOSCZeroAndTwo() async throws {
|
||||
let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in })
|
||||
let state = TerminalViewState(configSource: .none)
|
||||
state.configuration = TerminalSurfaceOptions(backend: .inMemory(backend))
|
||||
let terminal = TerminalView(frame: NSRect(x: 0, y: 0, width: 640, height: 400))
|
||||
terminal.delegate = state
|
||||
terminal.configuration = state.configuration
|
||||
terminal.controller = state.controller
|
||||
let window = NSWindow(
|
||||
contentRect: terminal.frame, styleMask: [.titled],
|
||||
backing: .buffered, defer: false
|
||||
)
|
||||
window.contentView = terminal
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
defer { window.close() }
|
||||
|
||||
backend.receive(Data("\u{1B}]0;zero-title\u{7}".utf8))
|
||||
try await waitUntil { state.title == "zero-title" }
|
||||
|
||||
backend.receive(Data("\u{1B}]2;two-title\u{7}".utf8))
|
||||
try await waitUntil { state.title == "two-title" }
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: Duration = .seconds(2), condition: @MainActor () -> Bool
|
||||
) async throws {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: timeout)
|
||||
while !condition() {
|
||||
guard clock.now < deadline else {
|
||||
Issue.record("Timed out waiting for terminal state")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated let ghosttyHasActiveDisplay: Bool = {
|
||||
var count: UInt32 = 0
|
||||
guard CGGetActiveDisplayList(0, nil, &count) == .success else { return false }
|
||||
return count > 0
|
||||
}()
|
||||
Reference in new issue
Block a user