Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection. Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
116 lines
4.5 KiB
Swift
116 lines
4.5 KiB
Swift
import Foundation
|
|
import Observation
|
|
import TermindDomain
|
|
|
|
public nonisolated struct TerminalCommandCompletion: Equatable, Sendable {
|
|
public var command: String?
|
|
public var exitCode: Int?
|
|
public var duration: TimeInterval
|
|
|
|
public init(command: String? = nil, exitCode: Int?, duration: TimeInterval) {
|
|
self.command = command
|
|
self.exitCode = exitCode
|
|
self.duration = duration
|
|
}
|
|
|
|
public var commandPreview: String? {
|
|
guard let command else { return nil }
|
|
let cleaned = command.unicodeScalars.map { scalar in
|
|
CharacterSet.controlCharacters.contains(scalar) ? " " : String(scalar)
|
|
}.joined().split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
|
|
guard !cleaned.isEmpty else { return nil }
|
|
return cleaned.count > 160 ? String(cleaned.prefix(159)) + "…" : cleaned
|
|
}
|
|
}
|
|
|
|
/// Per-terminal state, independent of history and transient view mounting.
|
|
@MainActor @Observable
|
|
public final class TerminalCommandMonitor {
|
|
public private(set) var unreadCompletionCount = 0
|
|
public var hasUnreadCompletion: Bool { unreadCompletionCount > 0 }
|
|
public var preferences: () -> NotificationPreferences = { .init() }
|
|
public var isWorkspaceFocused: () -> Bool = { false }
|
|
public var alert: (TerminalCommandCompletion, NotificationPreferences) -> Void = { _, _ in }
|
|
private let now: () -> TimeInterval
|
|
private var active = false
|
|
private var usesShellIntegration = false
|
|
private var running: (command: String, started: TimeInterval)?
|
|
private var contextualRunning: [TerminalContextIdentity: (command: String, started: TimeInterval)] = [:]
|
|
private var acceptsUnscopedCompletion = true
|
|
|
|
public init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
|
|
self.now = now
|
|
}
|
|
|
|
public func beginConnection() {
|
|
active = true
|
|
usesShellIntegration = false
|
|
running = nil
|
|
contextualRunning.removeAll()
|
|
acceptsUnscopedCompletion = true
|
|
}
|
|
|
|
public func endConnection() {
|
|
active = false
|
|
running = nil
|
|
usesShellIntegration = false
|
|
contextualRunning.removeAll()
|
|
}
|
|
|
|
public func markRead() { unreadCompletionCount = 0 }
|
|
|
|
public func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
|
|
guard active else { return }
|
|
switch event {
|
|
case .available:
|
|
usesShellIntegration = true
|
|
case .commandStarted(let command):
|
|
guard usesShellIntegration else { return }
|
|
running = (command, now())
|
|
case .commandFinished(let status, let reported):
|
|
guard usesShellIntegration, let command = running else { return }
|
|
running = nil
|
|
let duration =
|
|
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
|
|
?? max(0, now() - command.started)
|
|
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
|
|
default: break
|
|
}
|
|
}
|
|
|
|
public func receiveOSC133(exitCode: Int?, durationNanos: UInt64) {
|
|
guard active, acceptsUnscopedCompletion, !usesShellIntegration else { return }
|
|
complete(.init(exitCode: exitCode, duration: Double(durationNanos) / 1_000_000_000))
|
|
}
|
|
|
|
public func requireScopedEvents() {
|
|
acceptsUnscopedCompletion = false
|
|
}
|
|
|
|
public func receiveRuntimeEvents(_ events: [TerminalContextEvent]) {
|
|
guard active else { return }
|
|
acceptsUnscopedCompletion = false
|
|
for event in events {
|
|
switch event.payload {
|
|
case .commandStarted(let command):
|
|
contextualRunning[event.context] = (command, now())
|
|
case .commandFinished(let status, let reported):
|
|
guard let command = contextualRunning.removeValue(forKey: event.context) else { continue }
|
|
let duration =
|
|
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
|
|
?? max(0, now() - command.started)
|
|
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func complete(_ completion: TerminalCommandCompletion) {
|
|
let focused = isWorkspaceFocused()
|
|
unreadCompletionCount = focused ? 0 : unreadCompletionCount + 1
|
|
let settings = preferences()
|
|
guard settings.allowsTerminal(duration: completion.duration, isFocused: focused) else { return }
|
|
alert(completion, settings)
|
|
}
|
|
}
|