feat: refine terminal resize
This commit is contained in:
1 parent
ed7ed758db
commit
4fa7fcc428
9 files changed
+680
-27
No files matched your search
@@ -7,6 +7,114 @@ import Testing
|
||||
|
||||
@Suite
|
||||
struct ResumableTerminalEngineTests {
|
||||
@Test func restoreStartsWithTheLatestViewportAcceptedDuringPreparationWithoutRedundantResize() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["prepare"])
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:prepare")
|
||||
try await client.resize(columns: 100, rows: 30)
|
||||
try await client.resize(columns: 132, rows: 43)
|
||||
try await client.resize(columns: 132, rows: 43)
|
||||
#expect(!events.values.contains(where: { $0.hasPrefix("resize:") || $0.hasPrefix("start:") }))
|
||||
await engine.release("prepare")
|
||||
try await connecting.value
|
||||
#expect(await engine.preparedPayload == checkpoint.payload)
|
||||
#expect(events.values.filter { $0.hasPrefix("start:") } == ["start:132x43"])
|
||||
#expect(!events.values.contains(where: { $0.hasPrefix("resize:") }))
|
||||
#expect(await engine.appliedGrid == "132x43")
|
||||
#expect(events.values.last == "state:connected")
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@Test func startupPreservesTerminalProtocolRepliesBeforePublishingConnected() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["start"])
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:start")
|
||||
await events.emit(.connected)
|
||||
let reply = "\u{1B}[1;1R"
|
||||
try await client.write(Data(reply.utf8))
|
||||
#expect(events.values.contains("write:\(reply)"))
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
await engine.release("start")
|
||||
try await connecting.value
|
||||
#expect(events.values.last == "state:connected")
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@Test func startupDrainsResizeChangesAcrossStartAndResizeAwaitsBeforeConnected() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["start", "resize"])
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:start")
|
||||
try await client.resize(columns: 100, rows: 30)
|
||||
try await client.resize(columns: 132, rows: 43)
|
||||
await events.emit(.connected)
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
#expect(!events.values.contains(where: { $0.hasPrefix("resize:") }))
|
||||
await engine.release("start")
|
||||
await events.wait(for: "gated:resize")
|
||||
#expect(await engine.appliedGrid == "80x24")
|
||||
try await client.resize(columns: 150, rows: 50)
|
||||
try await client.resize(columns: 160, rows: 60)
|
||||
try await client.resize(columns: 160, rows: 60)
|
||||
await events.emit(.activity)
|
||||
#expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"])
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
await engine.release("resize")
|
||||
try await connecting.value
|
||||
#expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43", "resize:160x60"])
|
||||
#expect(await engine.appliedGrid == "160x60")
|
||||
#expect(events.values.last == "state:connected")
|
||||
try await client.write(Data("after".utf8))
|
||||
#expect(events.values.last == "write:after")
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@Test func startupRechecksViewportAfterWritingRestoredIntegrationBeforePublishingConnected() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["start", "write"], restoresIntegration: true)
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:start")
|
||||
await events.emit(.output(Data("__TRMD_RESTORE_SHELL__/bin/bash__\r\n".utf8)))
|
||||
#expect(!events.values.contains(where: { $0.hasPrefix("write:") }))
|
||||
await engine.release("start")
|
||||
await events.wait(for: "gated:write")
|
||||
try await client.resize(columns: 140, rows: 50)
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
#expect(!events.values.contains(where: { $0.hasPrefix("resize:") }))
|
||||
await engine.release("write")
|
||||
try await connecting.value
|
||||
#expect(events.values.filter { $0.hasPrefix("write:") && $0.contains("TermindCommand=RESTORE") }.count == 1)
|
||||
#expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:140x50"])
|
||||
#expect(await engine.appliedGrid == "140x50")
|
||||
#expect(events.values.last == "state:connected")
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@Test func cancellingStartupDuringResizeDrainClosesWithoutAdmittingConnectedOrInput() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["start", "resize"])
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:start")
|
||||
try await client.resize(columns: 132, rows: 43)
|
||||
await engine.release("start")
|
||||
await events.wait(for: "gated:resize")
|
||||
connecting.cancel()
|
||||
try await client.resize(columns: 160, rows: 60)
|
||||
await engine.release("resize")
|
||||
await #expect(throws: CancellationError.self) { try await connecting.value }
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
#expect(events.values.filter { $0.hasPrefix("resize:") } == ["resize:132x43"])
|
||||
#expect(Array(events.values.suffix(3)) == ["rollback", "close", "state:closed"])
|
||||
await #expect(throws: CancellationError.self) { try await client.write(Data("blocked".utf8)) }
|
||||
}
|
||||
|
||||
@Test func routeFactsPassWhileRecoveryIsFrozenWithoutConfirmingProtocolReadiness() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["retry"])
|
||||
@@ -295,18 +403,28 @@ struct ResumableTerminalEngineTests {
|
||||
await client.close()
|
||||
}
|
||||
|
||||
@Test func checkpointQueuedDuringInitialStartUsesTheConnectedEventAfterDrainingConnect() async throws {
|
||||
@Test func checkpointQueuedDuringInitialStartWaitsForTheLatestViewportToDrain() async throws {
|
||||
let events = EngineEvents()
|
||||
let engine = TestEngine(events: events, gates: ["start"])
|
||||
let engine = TestEngine(events: events, gates: ["start", "resize"])
|
||||
let client = client(engine, events: events)
|
||||
let connecting = Task { try await connect(client) }
|
||||
await events.wait(for: "gated:start")
|
||||
let suspending = Task { try await client.suspend() }
|
||||
await events.wait(for: "lifecycle:queued")
|
||||
try await client.resize(columns: 132, rows: 43)
|
||||
#expect(!events.values.contains("checkpoint"))
|
||||
await engine.release("start")
|
||||
await events.wait(for: "gated:resize")
|
||||
try await client.resize(columns: 160, rows: 60)
|
||||
#expect(!events.values.contains("checkpoint"))
|
||||
#expect(!events.values.contains("state:connected"))
|
||||
await engine.release("resize")
|
||||
try await connecting.value
|
||||
let saved = try await suspending.value
|
||||
#expect(await engine.appliedGrid == "160x60")
|
||||
let resizeIndex = try #require(events.values.firstIndex(of: "resize:160x60"))
|
||||
let checkpointIndex = try #require(events.values.firstIndex(of: "checkpoint"))
|
||||
#expect(resizeIndex < checkpointIndex)
|
||||
#expect(saved.transport == .mosh)
|
||||
#expect(events.values.last == "state:suspended")
|
||||
#expect(!events.values.contains("state:failed"))
|
||||
@@ -840,6 +958,7 @@ private actor TestEngine: ResumableTerminalEngine {
|
||||
let restoresIntegration: Bool
|
||||
let reportsConnectedOnStart: Bool
|
||||
private(set) var preparedPayload: Data?
|
||||
private(set) var appliedGrid: String?
|
||||
private(set) var permitsAgentForwarding = true
|
||||
private(set) var capturedShell: ResumableTerminalShellState?
|
||||
private var writing: CheckedContinuation<Void, Never>?
|
||||
@@ -864,6 +983,7 @@ private actor TestEngine: ResumableTerminalEngine {
|
||||
preparedPayload = context.checkpoint
|
||||
permitsAgentForwarding = context.request.permitsAgentForwarding
|
||||
events.record("prepare")
|
||||
await waitAtGate("prepare")
|
||||
return .init(
|
||||
shell: .init(
|
||||
integration: restoresIntegration ? RemoteShellIntegrationBootstrap(token: "RESTORE") : nil,
|
||||
@@ -873,11 +993,13 @@ private actor TestEngine: ResumableTerminalEngine {
|
||||
events.record("start:\(columns)x\(rows)")
|
||||
if failsStart { throw TerminalTransportError.unreachable }
|
||||
await waitAtGate("start")
|
||||
appliedGrid = "\(columns)x\(rows)"
|
||||
if reportsConnectedOnStart { await events.emit(.connected) }
|
||||
await events.emit(.output(Data("ready".utf8)))
|
||||
}
|
||||
func write(_ data: Data) async throws {
|
||||
events.record("write:\(String(decoding: data, as: UTF8.self))")
|
||||
await waitAtGate("write")
|
||||
if data == Data("hold".utf8) {
|
||||
await withCheckedContinuation { writing = $0 }
|
||||
}
|
||||
@@ -890,6 +1012,7 @@ private actor TestEngine: ResumableTerminalEngine {
|
||||
events.record("resize:\(columns)x\(rows)")
|
||||
await waitAtGate("resize")
|
||||
try failIfRequested("resize")
|
||||
appliedGrid = "\(columns)x\(rows)"
|
||||
}
|
||||
func checkpoint(shell: @Sendable () async -> ResumableTerminalShellState) async throws -> Data {
|
||||
events.record("checkpoint")
|
||||
|
||||
@@ -21,6 +21,7 @@ public actor ResumableTerminalClient: ResumableTerminalSession {
|
||||
private var hasObservedNetworkPath = false
|
||||
private var closed = false
|
||||
private var protocolSuspended = false
|
||||
private var startupNeedsCompletion = false
|
||||
private var recoveryNeedsCompletion = false
|
||||
private var suspendedCheckpoint: TerminalTransportCheckpoint?
|
||||
private var lifecycleOperations: [LifecycleOperation] = []
|
||||
@@ -36,10 +37,14 @@ public actor ResumableTerminalClient: ResumableTerminalSession {
|
||||
private var sendWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
private var viewport: (columns: UInt16, rows: UInt16) = (80, 24)
|
||||
|
||||
private var frozen: Bool {
|
||||
private var lifecycleFrozen: Bool {
|
||||
!lifecycleOperations.isEmpty || protocolSuspended || recoveryNeedsCompletion
|
||||
}
|
||||
|
||||
private var frozen: Bool {
|
||||
startupNeedsCompletion || lifecycleFrozen
|
||||
}
|
||||
|
||||
private enum LifecycleAction {
|
||||
case suspend, resume, retry
|
||||
}
|
||||
@@ -114,6 +119,7 @@ public actor ResumableTerminalClient: ResumableTerminalSession {
|
||||
await self?.receiveEngineEvent(event)
|
||||
}
|
||||
self.engine = engine
|
||||
startupNeedsCompletion = true
|
||||
sendsInFlight += 1
|
||||
defer { finishSend() }
|
||||
let bootstrap = SSHConnection()
|
||||
@@ -149,17 +155,38 @@ public actor ResumableTerminalClient: ResumableTerminalSession {
|
||||
)
|
||||
}
|
||||
}
|
||||
try await engine.start(columns: columns, rows: rows)
|
||||
try checkOpen()
|
||||
if let input = preparation.startupInput { try await engine.write(input) }
|
||||
if pendingCheckpoint == nil, let integration { try await engine.write(integration.startCommand) }
|
||||
if let command = pendingIntegrationCommand {
|
||||
pendingIntegrationCommand = nil
|
||||
try await engine.write(command)
|
||||
var appliedViewport = viewport
|
||||
try await engine.start(columns: appliedViewport.columns, rows: appliedViewport.rows)
|
||||
var startupInput = preparation.startupInput
|
||||
var integrationStartCommand = pendingCheckpoint == nil ? integration?.startCommand : nil
|
||||
var bootstrapDisconnected = false
|
||||
// Keep viewport delivery and connected publication ordered across every await.
|
||||
// No concurrent resize may complete before a stale start overwrites it.
|
||||
while true {
|
||||
try checkOpen()
|
||||
if appliedViewport.columns != viewport.columns || appliedViewport.rows != viewport.rows {
|
||||
let latest = viewport
|
||||
try await engine.resize(columns: latest.columns, rows: latest.rows)
|
||||
appliedViewport = latest
|
||||
} else if let input = startupInput {
|
||||
startupInput = nil
|
||||
try await engine.write(input)
|
||||
} else if let command = integrationStartCommand {
|
||||
integrationStartCommand = nil
|
||||
try await engine.write(command)
|
||||
} else if let command = pendingIntegrationCommand {
|
||||
pendingIntegrationCommand = nil
|
||||
try await engine.write(command)
|
||||
} else if !bootstrapDisconnected {
|
||||
await bootstrap.disconnect()
|
||||
bootstrapDisconnected = true
|
||||
} else {
|
||||
pendingCheckpoint = nil
|
||||
startupNeedsCompletion = false
|
||||
if !frozen { publish(engineState) }
|
||||
break
|
||||
}
|
||||
}
|
||||
await bootstrap.disconnect()
|
||||
pendingCheckpoint = nil
|
||||
guard !closed else { return }
|
||||
startMaintenance()
|
||||
startNetworkMonitoring()
|
||||
} catch {
|
||||
@@ -176,7 +203,9 @@ public actor ResumableTerminalClient: ResumableTerminalSession {
|
||||
|
||||
public func write(_ data: Data) async throws {
|
||||
try checkOpen()
|
||||
guard !frozen else { throw TerminalTransportError.suspended }
|
||||
// A starting surface can already answer terminal protocol queries. The host
|
||||
// gates interactive input on connected state; startup must not drop these replies.
|
||||
guard !lifecycleFrozen else { throw TerminalTransportError.suspended }
|
||||
integration?.noteUserInput()
|
||||
sendsInFlight += 1
|
||||
defer { finishSend() }
|
||||
|
||||
@@ -992,6 +992,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var socketEvents: SSHSocketEvents?
|
||||
private let pendingWrites = SSHByteQueue(chunkSize: 32_700)
|
||||
private var ptyResize = SSHPTYResizeState()
|
||||
private var ptyStartupSizing = SSHPTYStartupSizing()
|
||||
private var terminalActivation = SSHPTYActivationState()
|
||||
private var activationTask: (request: SSHPTYActivationState.Request, task: Task<Void, Never>)?
|
||||
private var shellIntegration: RemoteShellIntegrationBootstrap?
|
||||
@@ -1626,7 +1627,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
let variables = environment.map { ($0.0, SSHPTYStartupBytes($0.1), SSHPTYStartupBytes($0.2)) }
|
||||
let initialSize = try await queue.perform { self.ptyResize.requestedSize }
|
||||
guard let budget = try await queue.perform({ self.channelBudget }) else { throw TermindSSHError.disconnected }
|
||||
let lease = try await budget.acquire()
|
||||
try await queue.perform { [self] in
|
||||
@@ -1648,14 +1648,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
|
||||
return request.agentForwarding == nil ? 0 : libssh2_channel_request_auth_agent(channel)
|
||||
case 2:
|
||||
guard let channel = openingPTY, let size = initialSize else {
|
||||
return LIBSSH2_ERROR_CHANNEL_CLOSED
|
||||
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
|
||||
return ptyStartupSizing.requestPTY(resize: &ptyResize) { size in
|
||||
libssh2_channel_request_pty_ex(
|
||||
channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows),
|
||||
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels))
|
||||
}
|
||||
let result = libssh2_channel_request_pty_ex(
|
||||
channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows),
|
||||
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels))
|
||||
if result == 0 { ptyResize.didCreatePTY(size: size) }
|
||||
return result
|
||||
case 3..<(3 + environment.count):
|
||||
let variable = variables[operation - 3]
|
||||
let result = libssh2_channel_setenv_ex(
|
||||
@@ -1666,10 +1664,21 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
return result
|
||||
default:
|
||||
if let command {
|
||||
return libssh2_channel_process_startup(openingPTY, "exec", 4, command.pointer, command.count)
|
||||
}
|
||||
return libssh2_channel_process_startup(openingPTY, "shell", 5, nil, 0)
|
||||
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
|
||||
return ptyStartupSizing.startProcess(
|
||||
resize: &ptyResize,
|
||||
sendResize: { size in
|
||||
libssh2_channel_request_pty_size_ex(
|
||||
channel, Int32(size.columns), Int32(size.rows),
|
||||
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels))
|
||||
},
|
||||
start: {
|
||||
if let command {
|
||||
return libssh2_channel_process_startup(
|
||||
channel, "exec", 4, command.pointer, command.count)
|
||||
}
|
||||
return libssh2_channel_process_startup(channel, "shell", 5, nil, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2519,6 +2528,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
finishCharsetOutput()
|
||||
pendingWrites.removeAll()
|
||||
ptyResize.reset()
|
||||
ptyStartupSizing = SSHPTYStartupSizing()
|
||||
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
|
||||
shellIntegration = nil
|
||||
commandEventFilter = nil
|
||||
@@ -2553,6 +2563,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
self.connection = nil
|
||||
pendingWrites.removeAll()
|
||||
ptyResize.reset()
|
||||
ptyStartupSizing = SSHPTYStartupSizing()
|
||||
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
|
||||
shellIntegration = nil
|
||||
commandEventFilter = nil
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import CSSH2
|
||||
|
||||
/// Queue-confined startup sizing. A PTY request and a process request each retain
|
||||
/// their own arguments across EAGAIN; newer geometry is flushed between them.
|
||||
nonisolated struct SSHPTYStartupSizing {
|
||||
private var requestedSize: SSHPTYSize?
|
||||
private var processRequestStarted = false
|
||||
|
||||
mutating func requestPTY(
|
||||
resize: inout SSHPTYResizeState, send: (SSHPTYSize) -> Int32
|
||||
) -> Int32 {
|
||||
if requestedSize == nil { requestedSize = resize.requestedSize }
|
||||
guard let size = requestedSize else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
|
||||
let result = send(size)
|
||||
if result == 0 { resize.didCreatePTY(size: size) }
|
||||
return result
|
||||
}
|
||||
|
||||
mutating func startProcess(
|
||||
resize: inout SSHPTYResizeState,
|
||||
sendResize: (SSHPTYSize) -> Int32,
|
||||
start: () -> Int32
|
||||
) -> Int32 {
|
||||
if !processRequestStarted {
|
||||
let result = resize.flush(send: sendResize)
|
||||
guard result == 0 else { return result }
|
||||
processRequestStarted = true
|
||||
}
|
||||
return start()
|
||||
}
|
||||
}
|
||||
@@ -371,6 +371,7 @@
|
||||
private var appliedVisibility: Bool?
|
||||
private var wantsFocus = false
|
||||
private var nextMountOrdinal: UInt64 = 0
|
||||
private(set) var resizeThrottleMilliseconds: Double = 35
|
||||
|
||||
private var uploadBindingID: UUID?
|
||||
private var sftpBindingID: UUID?
|
||||
@@ -468,6 +469,15 @@
|
||||
terminalView = terminal
|
||||
}
|
||||
|
||||
/// Changes only the live coalescing policy; configuration and surface identity stay intact.
|
||||
@discardableResult
|
||||
func setResizeThrottle(milliseconds: Double) -> Bool {
|
||||
guard resizeThrottleMilliseconds != milliseconds else { return false }
|
||||
resizeThrottleMilliseconds = milliseconds
|
||||
terminalView.setResizeThrottle(milliseconds: milliseconds)
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated func receive(_ data: Data) {
|
||||
backend.receive(data)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
didSet {
|
||||
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
|
||||
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
|
||||
synchronizeResizeThrottle()
|
||||
}
|
||||
}
|
||||
var workingDirectory: String? {
|
||||
@@ -360,6 +361,22 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
writeInputDirectly(Data((text + (submit ? "\r" : "")).utf8))
|
||||
}
|
||||
|
||||
/// Runtime prompt snapshots can outlive foreground ownership or tmux copy/prefix mode.
|
||||
/// A read-draft ticket confirms that the prompt is still interactive.
|
||||
private func synchronizeResizeThrottle() {
|
||||
let milliseconds: Double
|
||||
if !status.isLive || !isShellIntegrationEnabled || !shellIntegrationAvailable {
|
||||
milliseconds = 35
|
||||
} else if isShellPromptReady,
|
||||
!hasRuntimeContext || terminalContext.ticket(requiring: .readDraft) != nil
|
||||
{
|
||||
milliseconds = 0
|
||||
} else {
|
||||
milliseconds = 128
|
||||
}
|
||||
terminalSurface.setResizeThrottle(milliseconds: milliseconds)
|
||||
}
|
||||
|
||||
var canGenerateFromCurrentDraft: Bool {
|
||||
status.isLive && isShellIntegrationEnabled && shellIntegrationAvailable && isShellPromptReady
|
||||
&& TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "")
|
||||
@@ -473,6 +490,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
activeShellCommand = nil
|
||||
commandDraft.cancel()
|
||||
invalidateCommandGeneration()
|
||||
synchronizeResizeThrottle()
|
||||
}
|
||||
|
||||
func connectIfNeeded(using store: any RemoteSessionHostStore) {
|
||||
@@ -1009,6 +1027,16 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
}
|
||||
}
|
||||
await actor.setTerminalActive(isTerminalActive)
|
||||
guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() }
|
||||
if transport == .ssh {
|
||||
// A retained surface may not emit another resize for this new channel.
|
||||
// Prime all dimensions before connect consumes the worker's initial viewport.
|
||||
let viewport = latestViewport
|
||||
await actor.resize(
|
||||
columns: viewport.columns, rows: viewport.rows,
|
||||
widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels)
|
||||
}
|
||||
guard connectionAttemptID == attemptID, ssh === actor else { throw CancellationError() }
|
||||
try await actor.connect(
|
||||
using: SSHConnectionRequest(
|
||||
configuration: configuration,
|
||||
@@ -1377,9 +1405,13 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
}
|
||||
|
||||
private func resizeSSH(_ viewport: InMemoryTerminalViewport) {
|
||||
guard viewport.columns > 0, viewport.rows > 0 else { return }
|
||||
latestViewport = viewport
|
||||
guard let ssh else { return }
|
||||
Task {
|
||||
let attemptID = connectionAttemptID
|
||||
Task { [weak self] in
|
||||
guard let self, self.ssh === ssh, self.connectionAttemptID == attemptID else { return }
|
||||
let viewport = self.latestViewport
|
||||
await ssh.resize(
|
||||
columns: viewport.columns, rows: viewport.rows,
|
||||
widthPixels: viewport.widthPixels, heightPixels: viewport.heightPixels
|
||||
@@ -1438,6 +1470,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
return
|
||||
}
|
||||
guard !hasRuntimeContext else { return }
|
||||
defer { synchronizeResizeThrottle() }
|
||||
commandMonitor.receiveShellEvent(event)
|
||||
switch event {
|
||||
case .runtime, .ordinaryStartupUnavailable: break
|
||||
@@ -1483,6 +1516,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
}
|
||||
|
||||
private func receiveRuntimeContext(_ update: TerminalRuntimeUpdate) {
|
||||
defer { synchronizeResizeThrottle() }
|
||||
hasRuntimeContext = true
|
||||
let previous = terminalContext.revision
|
||||
let events = terminalContext.receive(update, generation: terminalContext.generation)
|
||||
|
||||
@@ -9,6 +9,111 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct HostShellIntegrationSessionTests {
|
||||
@Test func resizeThrottleFollowsLegacyLifecycleRatherThanCommandCompletion() {
|
||||
let session = SSHSessionModel(hostID: UUID(), title: "Resize lifecycle")
|
||||
defer { session.disconnect() }
|
||||
let owner = session.terminalViewOwner
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
session.status = .connected
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(owner.resizeThrottleMilliseconds == 0)
|
||||
session.receiveShellIntegrationEvent(.commandStarted("top"))
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
session.receiveShellIntegrationEvent(.commandFinished(0, duration: 1))
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(owner.resizeThrottleMilliseconds == 0)
|
||||
session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
session.status = .reconnecting
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
session.status = .connected
|
||||
#expect(owner.resizeThrottleMilliseconds == 0)
|
||||
session.status = .failed("Disconnected")
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
}
|
||||
|
||||
@Test(arguments: [TerminalRuntimeUpdate.Status.ordinary, .multiplexer])
|
||||
func runtimeResizeThrottleRequiresLifecycleAndInteractivePrompt(status: TerminalRuntimeUpdate.Status) {
|
||||
let session = SSHSessionModel(hostID: UUID(), title: "Runtime resize")
|
||||
defer { session.disconnect() }
|
||||
session.status = .connected
|
||||
let owner = session.terminalViewOwner
|
||||
var context = TerminalShellContext(
|
||||
identity: .init(backend: "tmux", server: "s", session: "s", pane: "a", shellInstance: "i"),
|
||||
shell: "bash", directory: nil, home: nil, promptReady: false, sequence: 1,
|
||||
capabilities: [.commandLifecycle])
|
||||
func receive() {
|
||||
session.receiveShellIntegrationEvent(
|
||||
.runtime(.init(status: status, active: context.identity, contexts: [context])))
|
||||
context.sequence += 1
|
||||
}
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
// A prompt snapshot without readDraft also represents tmux copy/prefix
|
||||
// mode or a foreground process that is no longer the shell.
|
||||
context.promptReady = true
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
context.capabilities.insert(.readDraft)
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 0)
|
||||
#expect(!session.canGenerateFromCurrentDraft) // Resize does not require replaceDraft.
|
||||
context.capabilities.remove(.readDraft)
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
context.capabilities = [.readDraft]
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
context.capabilities.insert(.commandLifecycle)
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 0)
|
||||
context.promptReady = false
|
||||
receive()
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
for unavailable in [TerminalRuntimeUpdate.Status.unknown, .unavailable] {
|
||||
session.receiveShellIntegrationEvent(.runtime(.init(status: unavailable)))
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
session.receiveShellIntegrationEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
|
||||
session.receiveShellIntegrationEvent(.promptReady)
|
||||
#expect(owner.resizeThrottleMilliseconds == 35)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func reconnectResetsResizeThrottleUntilFreshShellStateArrives() {
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
|
||||
let library = HostLibraryModel(hosts: [host])
|
||||
let session = SSHSessionModel(hostID: host.id, title: host.name)
|
||||
defer { session.disconnect() }
|
||||
// Missing authentication keeps setup local; no socket is opened.
|
||||
session.connect(using: library)
|
||||
session.status = .connected
|
||||
let context = TerminalShellContext(
|
||||
identity: .init(backend: "shell", server: "s", session: "s", pane: "a", shellInstance: "i"),
|
||||
shell: "bash", directory: nil, home: nil, promptReady: true, sequence: 1,
|
||||
capabilities: [.commandLifecycle, .readDraft])
|
||||
session.receiveShellIntegrationEvent(
|
||||
.runtime(.init(status: .ordinary, active: context.identity, contexts: [context])))
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == 0)
|
||||
session.disconnect()
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35)
|
||||
session.connect(using: library)
|
||||
session.status = .connected
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35)
|
||||
session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown)))
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == 35)
|
||||
session.receiveShellIntegrationEvent(
|
||||
.runtime(.init(status: .ordinary, active: context.identity, contexts: [context])))
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == 0)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func ordinaryStartupFallbackSubmitsOnceButNeverIntoManagedAttachments(managed: Bool) {
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
|
||||
@@ -169,6 +274,7 @@ struct HostShellIntegrationSessionTests {
|
||||
let pane = Pane(.sshSession(session))
|
||||
#expect(pane.remoteSystemInfo == (expected ? info : nil))
|
||||
#expect(session.canGenerateFromCurrentDraft == expected)
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == (expected ? 0 : 35))
|
||||
|
||||
defaults.set(!global, forKey: SettingsKey.shellIntegration)
|
||||
host.shellIntegrationMode = expected ? .disabled : .enabled
|
||||
@@ -176,6 +282,7 @@ struct HostShellIntegrationSessionTests {
|
||||
#expect(session.isShellIntegrationEnabled == expected)
|
||||
#expect(pane.remoteSystemInfo == (expected ? info : nil))
|
||||
#expect(session.canGenerateFromCurrentDraft == expected)
|
||||
#expect(session.terminalViewOwner.resizeThrottleMilliseconds == (expected ? 0 : 35))
|
||||
session.receiveShellIntegrationEvent(.commandStarted("pwd"))
|
||||
session.receiveShellIntegrationEvent(.commandFinished(0, duration: 0.1))
|
||||
#expect(history.entries().count == (expected ? 1 : 0))
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import CSSH2
|
||||
import Testing
|
||||
|
||||
@testable import TermindRemoteInfrastructure
|
||||
|
||||
@Suite
|
||||
struct SSHPTYStartupSizingTests {
|
||||
@Test func initialRequestUsesTheViewportAfterChannelAdmission() {
|
||||
var resize = SSHPTYResizeState()
|
||||
_ = resize.beginConnection(columns: 80, rows: 24)
|
||||
var startup = SSHPTYStartupSizing()
|
||||
let latest = SSHPTYSize(columns: 132, rows: 43, widthPixels: 2112, heightPixels: 1376)
|
||||
resize.request(latest)
|
||||
var requests: [SSHPTYSize] = []
|
||||
#expect(
|
||||
startup.requestPTY(resize: &resize) {
|
||||
requests.append($0)
|
||||
return 0
|
||||
} == 0)
|
||||
var starts = 0
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: { _ in
|
||||
Issue.record("The initial viewport was sent twice")
|
||||
return 0
|
||||
},
|
||||
start: {
|
||||
starts += 1
|
||||
return 0
|
||||
}) == 0)
|
||||
#expect(requests == [latest])
|
||||
#expect(starts == 1)
|
||||
#expect(resize.isSynchronized)
|
||||
}
|
||||
|
||||
@Test func aBlockedPTYRequestKeepsItsSizeThenFlushesTheLatestBeforeStarting() {
|
||||
var resize = SSHPTYResizeState()
|
||||
_ = resize.beginConnection(columns: 80, rows: 24)
|
||||
let initial = SSHPTYSize(columns: 80, rows: 24)
|
||||
let latest = SSHPTYSize(columns: 140, rows: 50, widthPixels: 2240, heightPixels: 1600)
|
||||
var startup = SSHPTYStartupSizing()
|
||||
var requests: [SSHPTYSize] = []
|
||||
#expect(
|
||||
startup.requestPTY(resize: &resize) {
|
||||
requests.append($0)
|
||||
return LIBSSH2_ERROR_EAGAIN
|
||||
} == LIBSSH2_ERROR_EAGAIN)
|
||||
resize.request(latest)
|
||||
#expect(
|
||||
startup.requestPTY(resize: &resize) {
|
||||
requests.append($0)
|
||||
return 0
|
||||
} == 0)
|
||||
var events: [String] = []
|
||||
var delivered: [SSHPTYSize] = []
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: {
|
||||
delivered.append($0)
|
||||
events.append("resize")
|
||||
return 0
|
||||
},
|
||||
start: {
|
||||
events.append("start")
|
||||
return 0
|
||||
}) == 0)
|
||||
#expect(requests == [initial, initial])
|
||||
#expect(delivered == [latest])
|
||||
#expect(events == ["resize", "start"])
|
||||
#expect(resize.isSynchronized)
|
||||
}
|
||||
|
||||
@Test func windowChangeRetriesFinishBeforeTheProcessRequestBegins() {
|
||||
var resize = SSHPTYResizeState()
|
||||
_ = resize.beginConnection(columns: 80, rows: 24)
|
||||
var startup = SSHPTYStartupSizing()
|
||||
#expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0)
|
||||
let blocked = SSHPTYSize(columns: 100, rows: 30)
|
||||
let latest = SSHPTYSize(columns: 140, rows: 50)
|
||||
resize.request(blocked)
|
||||
var attempts: [SSHPTYSize] = []
|
||||
var starts = 0
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: {
|
||||
attempts.append($0)
|
||||
return LIBSSH2_ERROR_EAGAIN
|
||||
},
|
||||
start: {
|
||||
starts += 1
|
||||
return 0
|
||||
}) == LIBSSH2_ERROR_EAGAIN)
|
||||
#expect(starts == 0)
|
||||
resize.request(latest)
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: {
|
||||
attempts.append($0)
|
||||
return 0
|
||||
},
|
||||
start: {
|
||||
starts += 1
|
||||
return 0
|
||||
}) == 0)
|
||||
#expect(attempts == [blocked, blocked, latest])
|
||||
#expect(starts == 1)
|
||||
}
|
||||
|
||||
@Test func aBlockedProcessRequestIsNotInterruptedByAnotherWindowChange() {
|
||||
var resize = SSHPTYResizeState()
|
||||
_ = resize.beginConnection(columns: 80, rows: 24)
|
||||
var startup = SSHPTYStartupSizing()
|
||||
#expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0)
|
||||
var starts = 0
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: { _ in
|
||||
Issue.record("Unchanged viewport sent")
|
||||
return 0
|
||||
},
|
||||
start: {
|
||||
starts += 1
|
||||
return LIBSSH2_ERROR_EAGAIN
|
||||
}) == LIBSSH2_ERROR_EAGAIN)
|
||||
let latest = SSHPTYSize(columns: 120, rows: 40)
|
||||
resize.request(latest)
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: { _ in
|
||||
Issue.record("Interrupted a pending process request")
|
||||
return 0
|
||||
},
|
||||
start: {
|
||||
starts += 1
|
||||
return 0
|
||||
}) == 0)
|
||||
var delivered: [SSHPTYSize] = []
|
||||
#expect(
|
||||
resize.flush {
|
||||
delivered.append($0)
|
||||
return 0
|
||||
} == 0)
|
||||
#expect(starts == 2)
|
||||
#expect(delivered == [latest])
|
||||
}
|
||||
|
||||
@Test func aFailedWindowChangeDoesNotStartTheProcess() {
|
||||
var resize = SSHPTYResizeState()
|
||||
_ = resize.beginConnection(columns: 80, rows: 24)
|
||||
var startup = SSHPTYStartupSizing()
|
||||
#expect(startup.requestPTY(resize: &resize) { _ in 0 } == 0)
|
||||
resize.request(.init(columns: 120, rows: 40))
|
||||
#expect(
|
||||
startup.startProcess(
|
||||
resize: &resize,
|
||||
sendResize: { _ in LIBSSH2_ERROR_SOCKET_SEND },
|
||||
start: {
|
||||
Issue.record("Started after a failed window change")
|
||||
return 0
|
||||
}) == LIBSSH2_ERROR_SOCKET_SEND)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import AppKit
|
||||
import GhosttyTerminal
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
/// These windows are never ordered front or made key; layout and surface lifetime
|
||||
/// regressions remain covered by the default suite without stealing focus.
|
||||
@Suite(.serialized) @MainActor
|
||||
struct TerminalResizeThrottleTests {
|
||||
@Test func liveThrottleChangesAreIdempotentAndSurviveRemountWithoutReplacingTheSurface() async throws {
|
||||
let fixture = TerminalResizeFixture()
|
||||
defer { fixture.close() }
|
||||
try await fixture.waitForSurface()
|
||||
let owner = fixture.owner
|
||||
let state = owner.state
|
||||
let surface = try #require(state.surface)
|
||||
let controller = state.controller
|
||||
let view = try #require(fixture.mount.subviews.first as? TermindTerminalView)
|
||||
guard case .inMemory(let backend) = state.configuration.backend else {
|
||||
Issue.record("Expected the persistent in-memory backend")
|
||||
return
|
||||
}
|
||||
backend.receive(Data("preserved resize transcript".utf8))
|
||||
backend.waitForPendingOutput()
|
||||
#expect(!owner.setResizeThrottle(milliseconds: 35))
|
||||
for milliseconds in [128.0, 0.0, 35.0, 128.0] {
|
||||
#expect(owner.setResizeThrottle(milliseconds: milliseconds))
|
||||
#expect(!owner.setResizeThrottle(milliseconds: milliseconds))
|
||||
#expect(state.surface === surface)
|
||||
#expect(state.controller === controller)
|
||||
#expect(state.configuration.resizeThrottleMilliseconds == 35)
|
||||
#expect(view.configuration.resizeThrottleMilliseconds == 35)
|
||||
}
|
||||
let target = TerminalMountView(frame: fixture.mount.frame)
|
||||
fixture.window.contentView?.addSubview(target)
|
||||
defer { owner.unmount(from: target) }
|
||||
owner.mount(in: target, state: state, isVisible: true, requestsFocus: false)
|
||||
target.layoutSubtreeIfNeeded()
|
||||
owner.unmount(from: fixture.mount)
|
||||
#expect(target.subviews.first === view)
|
||||
#expect(state.surface === surface)
|
||||
#expect(state.controller === controller)
|
||||
#expect(view.controller === controller)
|
||||
#expect(owner.resizeThrottleMilliseconds == 128)
|
||||
#expect(!owner.setResizeThrottle(milliseconds: 128))
|
||||
if case .inMemory(let currentBackend) = view.configuration.backend {
|
||||
#expect(currentBackend === backend)
|
||||
} else {
|
||||
Issue.record("Remount replaced the in-memory backend")
|
||||
}
|
||||
#expect(backend.readViewportText()?.contains("preserved resize transcript") == true)
|
||||
#expect(!fixture.window.isVisible)
|
||||
}
|
||||
|
||||
@Test(arguments: [0.0, 35.0, 128.0])
|
||||
func rapidResizeBurstConvergesWithoutFocusOrFurtherInput(milliseconds: Double) async throws {
|
||||
let fixture = TerminalResizeFixture()
|
||||
defer { fixture.close() }
|
||||
try await fixture.waitForSurface()
|
||||
let owner = fixture.owner
|
||||
let surface = try #require(owner.state.surface)
|
||||
let before = try #require(fixture.resizes.latest)
|
||||
owner.setResizeThrottle(milliseconds: milliseconds)
|
||||
for width in stride(from: 680, through: 1040, by: 40) {
|
||||
fixture.mount.setFrameSize(NSSize(width: CGFloat(width), height: 400))
|
||||
fixture.mount.layoutSubtreeIfNeeded()
|
||||
}
|
||||
let view = try #require(fixture.mount.subviews.first as? TermindTerminalView)
|
||||
let finalWidth = UInt32((view.bounds.width * fixture.window.backingScaleFactor).rounded(.down))
|
||||
for _ in 0..<200 where fixture.resizes.latest?.widthPixels != finalWidth {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
let after = try #require(fixture.resizes.latest)
|
||||
#expect(after.widthPixels == finalWidth)
|
||||
#expect(after.columns > before.columns)
|
||||
let count = fixture.resizes.count
|
||||
for _ in 0..<10 {
|
||||
fixture.mount.setFrameSize(fixture.mount.frame.size)
|
||||
fixture.mount.needsLayout = true
|
||||
fixture.mount.layoutSubtreeIfNeeded()
|
||||
}
|
||||
// Let both the 128 ms trailing edge and its final rearm settle.
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(fixture.resizes.count == count)
|
||||
#expect(owner.state.surface === surface)
|
||||
#expect(!fixture.window.isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TerminalResizeFixture {
|
||||
nonisolated final class Resizes: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var viewports: [InMemoryTerminalViewport] = []
|
||||
|
||||
func receive(_ viewport: InMemoryTerminalViewport) {
|
||||
lock.withLock { viewports.append(viewport) }
|
||||
}
|
||||
|
||||
var latest: InMemoryTerminalViewport? { lock.withLock { viewports.last } }
|
||||
var count: Int { lock.withLock { viewports.count } }
|
||||
}
|
||||
|
||||
let resizes = Resizes()
|
||||
let owner: PersistentTerminalViewOwner
|
||||
let mount = TerminalMountView(frame: NSRect(x: 0, y: 0, width: 640, height: 400))
|
||||
let window: NSWindow
|
||||
|
||||
init() {
|
||||
let resizes = resizes
|
||||
owner = PersistentTerminalViewOwner(write: { _, _ in }, resize: { resizes.receive($0) })
|
||||
owner.state.setTerminalConfiguration(
|
||||
TerminalConfiguration { builder in
|
||||
builder.withWindowPaddingX(0)
|
||||
builder.withWindowPaddingY(0)
|
||||
})
|
||||
window = NSWindow(contentRect: mount.frame, styleMask: [.titled], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
let root = NSView(frame: mount.frame)
|
||||
window.contentView = root
|
||||
root.addSubview(mount)
|
||||
owner.mount(in: mount, state: owner.state, isVisible: true, requestsFocus: false)
|
||||
mount.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func waitForSurface() async throws {
|
||||
for _ in 0..<100 where owner.state.surface == nil || resizes.latest == nil {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
_ = try #require(owner.state.surface)
|
||||
_ = try #require(resizes.latest)
|
||||
}
|
||||
|
||||
func close() {
|
||||
owner.unmount(from: mount)
|
||||
window.contentView = nil
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user