fix(terminal): restore and cache context-aware shell history
This commit is contained in:
1 parent
7725e7c1ae
commit
7e82343da4
11 files changed
+553
-27
No files matched your search
@@ -8,6 +8,42 @@
|
||||
|
||||
@Suite
|
||||
struct RemoteShellHistoryLoaderTests {
|
||||
@Test func explicitlyDisabledHistoryDoesNotOpenAControlChannel() async throws {
|
||||
let loader = RemoteShellHistoryLoader(
|
||||
charset: .utf8, resolveSource: { .init(shell: .bash, path: "") },
|
||||
execute: { _, _ in
|
||||
Issue.record("Disabled history must not read a default file")
|
||||
throw TerminalRuntimeError.capacityUnavailable
|
||||
})
|
||||
#expect(try await loader.load(source: nil).isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func channelCapacityRetriesAreBoundedAndCanRecover(permanentlyUnavailable: Bool) async throws {
|
||||
let attempts = ReadAttempts()
|
||||
let loader = RemoteShellHistoryLoader(
|
||||
charset: .utf8, resolveSource: { .init(shell: .bash, path: "/nonexistent-\(UUID())") },
|
||||
execute: { command, _ in
|
||||
let attempt = await attempts.next()
|
||||
if permanentlyUnavailable || attempt < 3 { throw TerminalRuntimeError.capacityUnavailable }
|
||||
return try Self.execute(command)
|
||||
})
|
||||
if permanentlyUnavailable {
|
||||
await #expect(throws: TerminalRuntimeError.self) { try await loader.load(source: nil) }
|
||||
} else {
|
||||
#expect(try await loader.load(source: nil).isEmpty)
|
||||
}
|
||||
#expect(await attempts.count == 3)
|
||||
}
|
||||
|
||||
private actor ReadAttempts {
|
||||
private(set) var count = 0
|
||||
func next() -> Int {
|
||||
count += 1
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
@Test func realZshHistoryPreservesBackslashesBeforeLogicalNewlines() async throws {
|
||||
let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: file) }
|
||||
|
||||
@@ -10,6 +10,114 @@ import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct SessionHistoryTests {
|
||||
@Test func returningToAPaneRestoresStableRowsWithoutAnotherRead() async {
|
||||
let model = CommandHistoryModel()
|
||||
let generation = UUID()
|
||||
let first = historyContext(pane: "a", path: "/a/history")
|
||||
let second = historyContext(pane: "b", path: "/b/history")
|
||||
let firstRead = HistoryReadGate()
|
||||
let secondRead = HistoryReadGate()
|
||||
model.load(using: firstRead, source: model.selectContext(first, generation: generation))
|
||||
await firstRead.waitForRead()
|
||||
await firstRead.finish([.init(command: "first file")])
|
||||
#expect(await settles { model.hasLoaded })
|
||||
let firstIDs = model.entries().map(\.id)
|
||||
let sharedFile = historyContext(pane: "shared", path: "/a/history", instance: "another-shell")
|
||||
model.load(using: firstRead, source: model.selectContext(sharedFile, generation: generation))
|
||||
#expect(model.hasLoaded)
|
||||
#expect(!model.isLoading)
|
||||
#expect(model.entries().map(\.id) == firstIDs)
|
||||
#expect(await firstRead.readCount == 1)
|
||||
model.load(using: secondRead, source: model.selectContext(second, generation: generation))
|
||||
await secondRead.waitForRead()
|
||||
await secondRead.finish([])
|
||||
#expect(await settles { model.hasLoaded })
|
||||
|
||||
model.load(using: firstRead, source: model.selectContext(first, generation: generation))
|
||||
#expect(model.hasLoaded)
|
||||
#expect(!model.isLoading)
|
||||
#expect(model.entries().map(\.id) == firstIDs)
|
||||
#expect(await firstRead.readCount == 1)
|
||||
model.selectContext(nil, generation: generation)
|
||||
#expect(model.entries().isEmpty)
|
||||
model.selectContext(first, generation: generation)
|
||||
#expect(model.entries().map(\.id) == firstIDs)
|
||||
model.load(using: secondRead, source: model.selectContext(second, generation: generation))
|
||||
#expect(model.hasLoaded)
|
||||
#expect(model.entries().isEmpty)
|
||||
#expect(await secondRead.readCount == 1)
|
||||
|
||||
model.selectContext(first, generation: UUID())
|
||||
#expect(!model.hasLoaded)
|
||||
#expect(model.entries().isEmpty)
|
||||
}
|
||||
|
||||
@Test func switchingPanesRejectsOldFileReadsAndKeepsObservedCommands() async throws {
|
||||
let model = CommandHistoryModel()
|
||||
let generation = UUID()
|
||||
let first = historyContext(pane: "a", path: "/a/history")
|
||||
let second = historyContext(pane: "b", path: "/b/history")
|
||||
let stale = HistoryReadGate()
|
||||
let current = HistoryReadGate()
|
||||
model.record("observed live command")
|
||||
model.load(using: stale, source: model.selectContext(first, generation: generation))
|
||||
await stale.waitForRead()
|
||||
model.load(using: current, source: model.selectContext(second, generation: generation))
|
||||
await current.waitForRead()
|
||||
await current.finish([.init(command: "pane b history")])
|
||||
#expect(await settles { model.hasLoaded })
|
||||
await stale.finish([.init(command: "pane a history")])
|
||||
for _ in 0..<20 { await Task.yield() }
|
||||
#expect(model.entries().map(\.command) == ["observed live command", "pane b history"])
|
||||
model.selectContext(nil, generation: generation)
|
||||
#expect(model.entries().map(\.command) == ["observed live command"])
|
||||
#expect(!model.hasLoaded)
|
||||
}
|
||||
|
||||
@Test func historyFileCacheExpiresOnNewConnectionOrFileButNotPromptSequence() async {
|
||||
let model = CommandHistoryModel()
|
||||
var generation = UUID()
|
||||
var context = historyContext(pane: "a", path: "/a/history")
|
||||
for change in 0..<3 {
|
||||
let gate = HistoryReadGate()
|
||||
model.load(using: gate, source: model.selectContext(context, generation: generation))
|
||||
await gate.waitForRead()
|
||||
await gate.finish([.init(command: "snapshot \(change)")])
|
||||
#expect(await settles { model.hasLoaded })
|
||||
var typing = context
|
||||
typing.sequence += 1
|
||||
typing.promptReady = false
|
||||
model.selectContext(typing, generation: generation)
|
||||
model.load(using: gate, source: typing.historySource)
|
||||
#expect(await gate.readCount == 1)
|
||||
#expect(model.entries().map(\.command) == ["snapshot \(change)"])
|
||||
switch change {
|
||||
case 0: context.historyPath = "/a/new history"
|
||||
case 1: generation = UUID()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unverifiedHistoryNeverFallsBackToAnOuterShellFile() {
|
||||
var context = historyContext(pane: "a", path: "/a/history")
|
||||
context.historyPath = nil
|
||||
#expect(context.historySource == nil)
|
||||
context.historyPath = "relative"
|
||||
#expect(context.historySource == nil)
|
||||
context.historyPath = ""
|
||||
#expect(context.historySource == .init(shell: .bash, path: ""))
|
||||
context.shell = "unknown"
|
||||
#expect(context.historySource == nil)
|
||||
}
|
||||
|
||||
private func historyContext(pane: String, path: String, instance: String = "shell") -> TerminalShellContext {
|
||||
.init(
|
||||
identity: .init(backend: "test", server: "server", session: "session", pane: pane, shellInstance: instance),
|
||||
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
|
||||
capabilities: [.commandLifecycle], historyPath: path)
|
||||
}
|
||||
|
||||
@Test func snapshotDoesNotReplaceLiveCommandsAndLoadsOnlyOnce() async throws {
|
||||
let gate = HistoryReadGate()
|
||||
let model = CommandHistoryModel()
|
||||
|
||||
@@ -15,6 +15,22 @@ public final class CommandHistoryModel {
|
||||
private var isClosed = false
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var loadID: UUID?
|
||||
private enum SnapshotScope: Equatable {
|
||||
case legacy
|
||||
case context(UUID, TerminalContextIdentity, ShellHistorySource)
|
||||
case unavailable(UUID)
|
||||
|
||||
func sharesFile(with other: Self) -> Bool {
|
||||
guard case .context(let generation, _, let source) = self,
|
||||
case .context(let otherGeneration, _, let otherSource) = other
|
||||
else { return false }
|
||||
return generation == otherGeneration && source == otherSource
|
||||
}
|
||||
}
|
||||
private var snapshotScope: SnapshotScope = .legacy
|
||||
private var importedIDs: Set<CommandHistoryEntry.ID> = []
|
||||
private var snapshotGeneration: UUID?
|
||||
private var snapshots: [(scope: SnapshotScope, entries: [CommandHistoryEntry])] = []
|
||||
|
||||
public init(sessionID: UUID = UUID(), entries: [CommandHistoryEntry] = []) {
|
||||
self.sessionID = sessionID
|
||||
@@ -65,6 +81,7 @@ public final class CommandHistoryModel {
|
||||
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
|
||||
self.merge(snapshot)
|
||||
self.hasLoaded = true
|
||||
self.cacheSnapshot()
|
||||
self.finishLoading()
|
||||
} catch {
|
||||
guard let self, self.loadID == token, !self.isClosed, !Task.isCancelled else { return }
|
||||
@@ -74,6 +91,41 @@ public final class CommandHistoryModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the selected shell's file snapshot is visible. Observed live commands remain terminal-wide.
|
||||
@discardableResult
|
||||
public func selectContext(_ context: TerminalShellContext?, generation: UUID) -> ShellHistorySource? {
|
||||
guard !isClosed else { return nil }
|
||||
if snapshotGeneration != generation {
|
||||
snapshots.removeAll()
|
||||
snapshotGeneration = generation
|
||||
}
|
||||
let source = context?.historySource
|
||||
let next: SnapshotScope
|
||||
if let context, let source {
|
||||
next = .context(generation, context.identity, source)
|
||||
} else {
|
||||
next = .unavailable(generation)
|
||||
}
|
||||
if snapshotScope != next {
|
||||
cancelLoading()
|
||||
values.removeAll { importedIDs.contains($0.id) }
|
||||
importedIDs.removeAll()
|
||||
hasLoaded = false
|
||||
loadError = nil
|
||||
snapshotScope = next
|
||||
if let index = snapshots.firstIndex(where: { $0.scope.sharesFile(with: next) }) {
|
||||
let cached = snapshots.remove(at: index)
|
||||
snapshots.append(cached)
|
||||
values.append(contentsOf: cached.entries)
|
||||
importedIDs = Set(cached.entries.map(\.id))
|
||||
trim()
|
||||
hasLoaded = true
|
||||
}
|
||||
revision &+= 1
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/// Disconnect cancels only the current read; reconnect retains a completed snapshot.
|
||||
public func cancelLoading() {
|
||||
loadID = nil
|
||||
@@ -86,6 +138,8 @@ public final class CommandHistoryModel {
|
||||
isClosed = true
|
||||
cancelLoading()
|
||||
values.removeAll()
|
||||
importedIDs.removeAll()
|
||||
snapshots.removeAll()
|
||||
loadError = nil
|
||||
revision &+= 1
|
||||
}
|
||||
@@ -95,6 +149,14 @@ public final class CommandHistoryModel {
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func cacheSnapshot() {
|
||||
guard case .context = snapshotScope else { return }
|
||||
snapshots.removeAll { $0.scope.sharesFile(with: snapshotScope) }
|
||||
snapshots.append((snapshotScope, values.filter { importedIDs.contains($0.id) }))
|
||||
// Keep recent pane snapshots bounded and memory-only, including successful empty reads.
|
||||
if snapshots.count > 16 { snapshots.removeFirst(snapshots.count - 16) }
|
||||
}
|
||||
|
||||
private func merge(_ snapshot: [ShellHistoryRecord]) {
|
||||
// Consume matches one at a time: repeated executions must not collapse into a set.
|
||||
var unmatched = values
|
||||
@@ -118,6 +180,7 @@ public final class CommandHistoryModel {
|
||||
sessionID: sessionID, command: command, executedAt: record.executedAt, duration: record.duration)
|
||||
}
|
||||
values.append(contentsOf: imported)
|
||||
importedIDs.formUnion(imported.map(\.id))
|
||||
trim()
|
||||
revision &+= 1
|
||||
}
|
||||
|
||||
@@ -36,6 +36,15 @@ public nonisolated struct TerminalShellContext: Sendable, Equatable {
|
||||
public var sequence: UInt64
|
||||
public var capabilities: TerminalContextCapabilities
|
||||
|
||||
/// Nil means unverified; an empty path explicitly disables file-backed history.
|
||||
public var historySource: ShellHistorySource? {
|
||||
guard let shell = ShellHistorySource.Shell(rawValue: shell),
|
||||
let historyPath, historyPath.isEmpty || historyPath.hasPrefix("/"),
|
||||
!historyPath.contains("\0")
|
||||
else { return nil }
|
||||
return .init(shell: shell, path: historyPath)
|
||||
}
|
||||
|
||||
public init(
|
||||
identity: TerminalContextIdentity, shell: String, directory: String?, home: String?,
|
||||
promptReady: Bool, sequence: UInt64, capabilities: TerminalContextCapabilities, historyPath: String? = nil
|
||||
|
||||
@@ -16,6 +16,16 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
|
||||
execute: { try await channel.executeHistoryRead($0, outputLimit: $1) })
|
||||
}
|
||||
|
||||
public init(channel: SSHPTYChannel, charset: RemoteCharset, context: TerminalShellContext) {
|
||||
self.init(
|
||||
charset: charset,
|
||||
resolveSource: {
|
||||
guard let source = context.historySource else { throw TerminalRuntimeError.staleContext }
|
||||
return source
|
||||
},
|
||||
execute: { try await channel.executeHistoryRead($0, outputLimit: $1, context: context) })
|
||||
}
|
||||
|
||||
init(
|
||||
charset: RemoteCharset,
|
||||
resolveSource: @escaping @Sendable () async throws -> ShellHistorySource?,
|
||||
@@ -41,8 +51,9 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
|
||||
private func read(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] {
|
||||
let resolved: ShellHistorySource?
|
||||
if let source { resolved = source } else { resolved = try await resolveSource() }
|
||||
if resolved?.path == "" { return [] }
|
||||
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let result = try await execute(Self.command(source: resolved, token: token), 3 * 1024 * 1024)
|
||||
let result = try await executeRead(Self.command(source: resolved, token: token))
|
||||
guard result.exitCode == 0, !result.stdoutTruncated else {
|
||||
throw TermindSSHError.library("History file could not be read.")
|
||||
}
|
||||
@@ -67,6 +78,19 @@ public nonisolated struct RemoteShellHistoryLoader: ShellHistoryLoading {
|
||||
.suffix(HistoryLimits.retainedEntries))
|
||||
}
|
||||
|
||||
private func executeRead(_ command: String) async throws -> SSHExecResult {
|
||||
// A runtime poll may briefly own the last free channel. Never queue behind the user's PTY.
|
||||
for attempt in 0..<3 {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
return try await execute(command, 3 * 1024 * 1024)
|
||||
} catch TerminalRuntimeError.capacityUnavailable where attempt < 2 {
|
||||
try await Task.sleep(for: .milliseconds(100 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw TerminalRuntimeError.capacityUnavailable
|
||||
}
|
||||
|
||||
static func command(source: ShellHistorySource?, token: String) -> String {
|
||||
let shell = source.map { quote($0.shell.rawValue) } ?? "\"${SHELL##*/}\""
|
||||
let path =
|
||||
|
||||
@@ -891,10 +891,13 @@ public actor SSHPTYChannel {
|
||||
try await worker.openSFTPChannel()
|
||||
}
|
||||
|
||||
public func executeHistoryRead(_ command: String, outputLimit: Int) async throws -> SSHExecResult {
|
||||
public func executeHistoryRead(
|
||||
_ command: String, outputLimit: Int, context: TerminalShellContext? = nil
|
||||
) async throws -> SSHExecResult {
|
||||
let cancellation = TransferCancellationToken()
|
||||
return try await withTaskCancellationHandler {
|
||||
try await worker.executeHistoryRead(command, outputLimit: outputLimit, cancellation: cancellation)
|
||||
try await worker.executeHistoryRead(
|
||||
command, outputLimit: outputLimit, cancellation: cancellation, context: context)
|
||||
} onCancel: {
|
||||
cancellation.cancel()
|
||||
}
|
||||
@@ -973,6 +976,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
|
||||
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
|
||||
private var detectedHistorySource: ShellHistorySource?
|
||||
private var allowsDefaultHistorySource = true
|
||||
|
||||
init(
|
||||
output: @escaping SSHPTYChannel.OutputHandler,
|
||||
@@ -1525,6 +1529,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
try await queue.perform { [self] in
|
||||
guard ptyResize.generation == generation else { throw CancellationError() }
|
||||
runtimeEnabled = runtimeIsEnabled
|
||||
allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled
|
||||
runtimeWantsObservation = runtimeIsEnabled && launch != nil
|
||||
if runtimeIsEnabled {
|
||||
contextFilter = .init(token: runtimeToken.uuidString, charset: charset)
|
||||
@@ -2197,12 +2202,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
latestRuntimeUpdate = update
|
||||
if update.status == .ordinary,
|
||||
let context = update.contexts.first(where: { $0.identity == update.active }),
|
||||
let path = context.historyPath, let shell = ShellHistorySource.Shell(rawValue: context.shell)
|
||||
{
|
||||
detectedHistorySource = .init(shell: shell, path: path)
|
||||
}
|
||||
detectedHistorySource =
|
||||
update.status == .ordinary || update.status == .multiplexer
|
||||
? update.contexts.first(where: { $0.identity == update.active })?.historySource : nil
|
||||
shellIntegrationHandler(.runtime(update))
|
||||
}
|
||||
|
||||
@@ -2423,6 +2425,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
runtimeTask = nil
|
||||
runtimeTerminal = nil
|
||||
runtimeEnabled = false
|
||||
allowsDefaultHistorySource = true
|
||||
runtimeWantsObservation = false
|
||||
runtimeControlUnavailable = false
|
||||
runtimeRevision &+= 1
|
||||
@@ -2587,11 +2590,60 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
|
||||
|
||||
nonisolated extension SSHPTYChannelWorker {
|
||||
fileprivate func executeHistoryRead(
|
||||
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken
|
||||
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken,
|
||||
context expected: TerminalShellContext?
|
||||
) async throws -> SSHExecResult {
|
||||
try await execChannelOwner.execute(
|
||||
let generation = try await queue.perform { [self] in
|
||||
guard connection != nil else { throw TermindSSHError.disconnected }
|
||||
return ptyResize.generation
|
||||
}
|
||||
if let expected { try await confirmHistoryContext(expected) }
|
||||
let result = try await execChannelOwner.execute(
|
||||
command, workingDirectory: nil, stdin: nil, timeout: 10,
|
||||
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false)
|
||||
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false,
|
||||
retriesChannelOpen: false, requiresImmediateCapacity: true,
|
||||
validateContext: { [self] in
|
||||
ptyResize.generation == generation
|
||||
&& (expected.map(matchesHistoryContext) ?? allowsDefaultHistorySource)
|
||||
})
|
||||
if let expected { try await confirmHistoryContext(expected) }
|
||||
try Task.checkCancellation()
|
||||
try await queue.perform { [self] in
|
||||
guard connection != nil, ptyResize.generation == generation else { throw CancellationError() }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func matchesHistoryContext(_ expected: TerminalShellContext) -> Bool {
|
||||
guard let update = latestRuntimeUpdate,
|
||||
update.status == .ordinary || update.status == .multiplexer,
|
||||
update.active == expected.identity, let source = expected.historySource
|
||||
else { return false }
|
||||
return update.contexts.first(where: { $0.identity == expected.identity })?.historySource == source
|
||||
}
|
||||
|
||||
private func confirmHistoryContext(_ expected: TerminalShellContext) async throws {
|
||||
let state = try await queue.perform { [self] in
|
||||
guard matchesHistoryContext(expected), let terminal = runtimeTerminal else {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
return (terminal, ptyResize.generation, charset)
|
||||
}
|
||||
if expected.identity.backend != "shell" {
|
||||
let execute = try await sessionCommandExecutor(runtime: true)
|
||||
let update = try await RemoteTmuxRuntimeAdapter(execute: execute, charset: state.2)
|
||||
.inspect(terminal: state.0)
|
||||
guard update.status == .multiplexer, update.active == expected.identity,
|
||||
update.contexts.first(where: { $0.identity == expected.identity })?.historySource
|
||||
== expected.historySource
|
||||
else { throw TerminalRuntimeError.staleContext }
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
try await queue.perform { [self] in
|
||||
guard ptyResize.generation == state.1, matchesHistoryContext(expected) else {
|
||||
throw TerminalRuntimeError.staleContext
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func historySource() async throws -> ShellHistorySource? {
|
||||
@@ -2600,11 +2652,9 @@ nonisolated extension SSHPTYChannelWorker {
|
||||
try Task.checkCancellation()
|
||||
let (source, pending) = try await queue.perform { [self] in
|
||||
guard connection != nil else { throw TermindSSHError.disconnected }
|
||||
return (
|
||||
detectedHistorySource,
|
||||
shellIntegration != nil
|
||||
|| (runtimeEnabled && runtimeHelper != nil && latestRuntimeUpdate?.active == nil)
|
||||
)
|
||||
// Runtime shells must use a context-bound read, even before the UI receives their update.
|
||||
guard allowsDefaultHistorySource else { throw TerminalRuntimeError.staleContext }
|
||||
return (detectedHistorySource, shellIntegration != nil)
|
||||
}
|
||||
if !pending || source != nil || ContinuousClock.now >= deadline { return source }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
|
||||
@@ -134,6 +134,7 @@ final class MobileWorkspace: Identifiable {
|
||||
private var activeHistoryID: UUID?
|
||||
let terminalContext = TerminalContextCoordinator()
|
||||
private var hasRuntimeContext = false
|
||||
private var historyCharset: RemoteCharset = .utf8
|
||||
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
|
||||
/// Resolved once at the beginning of each connection attempt.
|
||||
private(set) var isShellIntegrationEnabled = true
|
||||
@@ -649,6 +650,7 @@ final class MobileWorkspace: Identifiable {
|
||||
|
||||
private func resetCommandGeneratorShellState() {
|
||||
terminalContext.reset()
|
||||
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
|
||||
hasRuntimeContext = false
|
||||
contextHistory.removeAll()
|
||||
cancelCommandGeneration()
|
||||
@@ -742,6 +744,7 @@ final class MobileWorkspace: Identifiable {
|
||||
hasRuntimeContext = true
|
||||
let previous = terminalContext.revision
|
||||
let events = terminalContext.receive(update, generation: terminalContext.generation)
|
||||
loadContextHistory()
|
||||
commandMonitor.receiveRuntimeEvents(events)
|
||||
if previous != terminalContext.revision {
|
||||
commandDraft.cancel()
|
||||
@@ -775,6 +778,16 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContextHistory() {
|
||||
let context = terminalContext.active
|
||||
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
|
||||
guard descriptor.kind == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else {
|
||||
return
|
||||
}
|
||||
commandHistory.load(
|
||||
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
|
||||
}
|
||||
|
||||
private func restoreTransport() {
|
||||
guard !restoringTransport else { return }
|
||||
restoringTransport = true
|
||||
@@ -1023,6 +1036,7 @@ final class MobileWorkspace: Identifiable {
|
||||
handlers.exit,
|
||||
handlers.shellIntegration)
|
||||
ssh = session
|
||||
historyCharset = host.charset
|
||||
if transport.isResumable {
|
||||
recoveryRecord = TerminalRecoveryRecord(
|
||||
id: recoveryID, host: host, username: config.username, transport: transport)
|
||||
@@ -1190,9 +1204,13 @@ final class MobileWorkspace: Identifiable {
|
||||
proxySubmissions.removeAll()
|
||||
let wasReconnecting = connection.markConnected()
|
||||
connected = true
|
||||
if descriptor.kind == .ssh, let ssh, sessionPersistence.attachment == nil {
|
||||
commandHistory.load(
|
||||
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
|
||||
if descriptor.kind == .ssh, let ssh {
|
||||
if hasRuntimeContext, terminalContext.active?.historySource != nil {
|
||||
loadContextHistory()
|
||||
} else {
|
||||
commandHistory.load(
|
||||
using: RemoteShellHistoryLoader(channel: ssh, charset: host.charset), source: nil)
|
||||
}
|
||||
}
|
||||
status = String(localized: AppStrings.Connections.connected)
|
||||
journey.succeed()
|
||||
|
||||
@@ -94,6 +94,38 @@ private final class MobileSSHCommandConnectionBox {
|
||||
|
||||
@Suite @MainActor
|
||||
struct MobileCommandGeneratorTests {
|
||||
@Test(arguments: [false, true])
|
||||
func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async throws {
|
||||
let (host, library) = try commandFixture()
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: library)
|
||||
defer { workspace.disconnect() }
|
||||
if managed {
|
||||
workspace.sessionPersistence.attachment = .init(
|
||||
backend: .tmux, session: .init(id: "test", name: "Test", identity: Data()),
|
||||
hostID: host.id, endpoint: "test:22", username: "user", ownershipToken: UUID())
|
||||
}
|
||||
let context = TerminalShellContext(
|
||||
identity: .init(backend: "test", server: "s", session: "s", pane: "a", shellInstance: "i"),
|
||||
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
|
||||
capabilities: [.commandLifecycle], historyPath: "/home/test/history")
|
||||
workspace.receiveShellEvent(
|
||||
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
|
||||
workspace.commandHistory.load(using: HistoryLoader(), source: context.historySource)
|
||||
#expect(await eventually { workspace.commandHistory.hasLoaded })
|
||||
#expect(workspace.commandHistory.entries().map(\.command) == ["imported"])
|
||||
workspace.receiveShellEvent(
|
||||
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
|
||||
#expect(workspace.commandHistory.hasLoaded)
|
||||
workspace.receiveShellEvent(.runtime(.init(status: .unknown)))
|
||||
#expect(workspace.commandHistory.entries().isEmpty)
|
||||
#expect(!workspace.commandHistory.hasLoaded)
|
||||
}
|
||||
|
||||
private struct HistoryLoader: ShellHistoryLoading {
|
||||
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { [.init(command: "imported")] }
|
||||
}
|
||||
|
||||
@Test func runtimePaneInvalidationClearsDirectoryAndIgnoresUnscopedEvents() async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
host.shellIntegrationMode = .enabled
|
||||
|
||||
@@ -97,6 +97,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
private var activeShellCommand: ActiveShellCommand?
|
||||
let terminalContext = TerminalContextCoordinator()
|
||||
private var hasRuntimeContext = false
|
||||
private var historyCharset: RemoteCharset = .utf8
|
||||
private var contextHistory: [TerminalContextIdentity: UUID] = [:]
|
||||
private(set) var commandGeneratorRevision: UInt64 = 0
|
||||
private let commandDraft = TerminalCommandDraftController()
|
||||
@@ -411,6 +412,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
|
||||
private func resetCommandGeneratorShellState() {
|
||||
terminalContext.reset()
|
||||
if hasRuntimeContext { commandHistory.selectContext(nil, generation: terminalContext.generation) }
|
||||
hasRuntimeContext = false
|
||||
contextHistory.removeAll()
|
||||
integratedShell = nil
|
||||
@@ -877,6 +879,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
}
|
||||
)
|
||||
ssh = actor
|
||||
historyCharset = host.charset
|
||||
do {
|
||||
if transport.isResumable {
|
||||
let snapshot = store.additionalConnectionSnapshot(from: configuration)
|
||||
@@ -970,9 +973,15 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
temporaryHostKeys.removeAll()
|
||||
_ = connection.markConnected()
|
||||
status = .connected
|
||||
if transport == .ssh && sessionPersistence.attachment == nil {
|
||||
commandHistory.load(using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
|
||||
} else if sessionPersistence.attachment != nil {
|
||||
if transport == .ssh {
|
||||
if hasRuntimeContext, terminalContext.active?.historySource != nil {
|
||||
loadContextHistory()
|
||||
} else {
|
||||
commandHistory.load(
|
||||
using: RemoteShellHistoryLoader(channel: actor, charset: host.charset), source: nil)
|
||||
}
|
||||
}
|
||||
if sessionPersistence.attachment != nil {
|
||||
pendingInitialCommand = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
homeResolution = .unavailable
|
||||
@@ -1358,6 +1367,7 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
hasRuntimeContext = true
|
||||
let previous = terminalContext.revision
|
||||
let events = terminalContext.receive(update, generation: terminalContext.generation)
|
||||
loadContextHistory()
|
||||
commandMonitor.receiveRuntimeEvents(events)
|
||||
if previous != terminalContext.revision {
|
||||
commandDraft.cancel()
|
||||
@@ -1392,6 +1402,14 @@ final class SSHSessionModel: RemotePathDisplaying, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContextHistory() {
|
||||
let context = terminalContext.active
|
||||
let source = commandHistory.selectContext(context, generation: terminalContext.generation)
|
||||
guard transport == .ssh, let context, let source, let ssh, commandHistory.loadError == nil else { return }
|
||||
commandHistory.load(
|
||||
using: RemoteShellHistoryLoader(channel: ssh, charset: historyCharset, context: context), source: source)
|
||||
}
|
||||
|
||||
/// Submits an explicit one-shot launch command, or this connection's freshly
|
||||
/// resolved Host startup command. Keeping the History exclusion next to the
|
||||
/// write ensures it is armed only when the command actually runs.
|
||||
|
||||
@@ -9,6 +9,39 @@ import struct TermindDomain.Host
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct HostShellIntegrationSessionTests {
|
||||
@Test(arguments: [false, true])
|
||||
func paneHistoryInvalidationDoesNotDependOnSessionPersistence(managed: Bool) async {
|
||||
let session = SSHSessionModel(hostID: UUID(), title: "History")
|
||||
defer { session.disconnect() }
|
||||
if managed {
|
||||
session.sessionPersistence.attachment = .init(
|
||||
backend: .tmux, session: .init(id: "test", name: "Test", identity: Data()),
|
||||
hostID: session.hostID, endpoint: "test:22", username: "user", ownershipToken: UUID())
|
||||
}
|
||||
let context = TerminalShellContext(
|
||||
identity: .init(backend: "test", server: "s", session: "s", pane: "a", shellInstance: "i"),
|
||||
shell: "bash", directory: "/", home: "/home/test", promptReady: true, sequence: 1,
|
||||
capabilities: [.commandLifecycle], historyPath: "/home/test/history")
|
||||
session.receiveShellIntegrationEvent(
|
||||
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
|
||||
session.commandHistory.load(using: HistoryLoader(), source: context.historySource)
|
||||
for _ in 0..<1000 {
|
||||
if session.commandHistory.hasLoaded { break }
|
||||
await Task.yield()
|
||||
}
|
||||
#expect(session.commandHistory.entries().map(\.command) == ["imported"])
|
||||
session.receiveShellIntegrationEvent(
|
||||
.runtime(.init(status: .multiplexer, active: context.identity, contexts: [context])))
|
||||
#expect(session.commandHistory.hasLoaded)
|
||||
session.receiveShellIntegrationEvent(.runtime(.init(status: .unknown)))
|
||||
#expect(session.commandHistory.entries().isEmpty)
|
||||
#expect(!session.commandHistory.hasLoaded)
|
||||
}
|
||||
|
||||
private struct HistoryLoader: ShellHistoryLoading {
|
||||
func load(source: ShellHistorySource?) async throws -> [ShellHistoryRecord] { [.init(command: "imported")] }
|
||||
}
|
||||
|
||||
@Test func runtimeContextGatesEnhancementsWithoutPersistence() throws {
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: .enabled)
|
||||
let session = SSHSessionModel(hostID: host.id, title: host.name)
|
||||
|
||||
@@ -31,6 +31,68 @@ struct TerminalContextRuntimeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
|
||||
func managedHistoryReadsTheConfirmedPaneFileWithoutTerminalInput(shell: String) async throws {
|
||||
let fixture = try loadFixture()
|
||||
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
||||
let state = State()
|
||||
let channel = makeChannel(state)
|
||||
let root = "/tmp/termind-history-\(UUID().uuidString)"
|
||||
let firstPath = shell == "fish" ? root + "/fish/pane-a_history" : root + "/a ' history"
|
||||
let secondPath = shell == "fish" ? root + "/fish/pane-b_history" : root + "/b ' history"
|
||||
let setup = """
|
||||
mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish"))
|
||||
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-a", shell: shell))) > \(try RemoteTmuxCommands.quote(firstPath))
|
||||
printf %s \(try RemoteTmuxCommands.quote(historyContents("echo from-pane-b", shell: shell))) > \(try RemoteTmuxCommands.quote(secondPath))
|
||||
"""
|
||||
await channel.configureStartup { execute in
|
||||
_ = try await execute(setup)
|
||||
let backend = RemoteTmuxService(execute: execute)
|
||||
let owner = UUID()
|
||||
let session = try await backend.create(
|
||||
token: owner, startupCommand: nil, shellIntegration: true,
|
||||
environment: [
|
||||
.init(variable: "SHELL", value: "/bin/\(shell)"),
|
||||
.init(variable: "HISTFILE", value: firstPath),
|
||||
.init(variable: "XDG_DATA_HOME", value: root),
|
||||
.init(variable: "fish_history", value: "pane-a"),
|
||||
])
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
||||
ownershipToken: owner)
|
||||
await MainActor.run { state.reference = reference }
|
||||
return try SSHPTYStartupPlan(command: backend.launch(reference, created: true).command)
|
||||
}
|
||||
do {
|
||||
try await connect(channel, fixture)
|
||||
try await wait { state.coordinator.active?.promptReady == true }
|
||||
let first = try #require(state.coordinator.active)
|
||||
#expect(first.historySource?.path == firstPath)
|
||||
let firstLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: first)
|
||||
#expect(try await firstLoader.load(source: nil).map(\.command) == ["echo from-pane-a"])
|
||||
let execute = try await channel.runtimeCommandExecutor()
|
||||
_ = try await execute(
|
||||
"tmux split-window -h -t \(try RemoteTmuxCommands.quote(first.identity.pane))"
|
||||
+ " -e \(try RemoteTmuxCommands.quote("HISTFILE=" + secondPath)) -e fish_history=pane-b")
|
||||
try await wait {
|
||||
state.coordinator.active?.identity != first.identity && state.coordinator.active?.promptReady == true
|
||||
}
|
||||
let second = try #require(state.coordinator.active)
|
||||
#expect(second.historySource?.path == secondPath)
|
||||
await #expect(throws: TerminalRuntimeError.self) { try await firstLoader.load(source: nil) }
|
||||
let secondLoader = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: second)
|
||||
#expect(try await secondLoader.load(source: nil).map(\.command) == ["echo from-pane-b"])
|
||||
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
||||
try await cleanup(channel, state)
|
||||
} catch {
|
||||
if let execute = try? await channel.sessionCommandExecutor() {
|
||||
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
||||
}
|
||||
try? await cleanup(channel, state)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(2)), arguments: ["bash", "zsh", "fish"])
|
||||
func managedContextsDraftsAndInvisibleCompletions(shell: String) async throws {
|
||||
let fixture = try loadFixture()
|
||||
@@ -201,38 +263,76 @@ struct TerminalContextRuntimeTests {
|
||||
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
||||
let state = State()
|
||||
let channel = makeChannel(state)
|
||||
let root = "/tmp/termind-manual-history-\(UUID().uuidString)"
|
||||
let path = shell == "fish" ? root + "/fish/manual_history" : root + "/history"
|
||||
var stage = "ordinary draft"
|
||||
await channel.configureEnvironment([
|
||||
.init(variable: "TERMIND_TMUX_SHELL", value: "/bin/\(shell)"), .init(variable: "LC_ALL", value: "C.UTF-8"),
|
||||
])
|
||||
do {
|
||||
try await connect(channel, fixture)
|
||||
try await wait { state.coordinator.status == .ordinary }
|
||||
try await wait { state.coordinator.status == .ordinary && state.coordinator.active?.promptReady == true }
|
||||
let outer = try #require(state.coordinator.active)
|
||||
#expect(outer.shell == shell)
|
||||
await channel.write(Data("echo scratch".utf8))
|
||||
#expect(try await channel.contextDraft(outer) == "echo scratch")
|
||||
await channel.write(Data([0x15]))
|
||||
stage = "ordinary history"
|
||||
let outerHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: outer)
|
||||
_ = try await outerHistory.load(source: nil)
|
||||
await #expect(throws: TerminalRuntimeError.self) {
|
||||
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
||||
}
|
||||
let execute = try await channel.runtimeCommandExecutor()
|
||||
_ = try await execute(
|
||||
"mkdir -p \(try RemoteTmuxCommands.quote(root + "/fish")); printf %s "
|
||||
+ "\(try RemoteTmuxCommands.quote(historyContents("echo manual-pane", shell: shell)))"
|
||||
+ " > \(try RemoteTmuxCommands.quote(path))")
|
||||
let backend = RemoteTmuxService(execute: execute)
|
||||
let owner = UUID()
|
||||
let session = try await backend.create(token: owner, startupCommand: "cd /tmp", shellIntegration: true)
|
||||
let session = try await backend.create(
|
||||
token: owner, startupCommand: "cd /tmp", shellIntegration: true,
|
||||
environment: [
|
||||
.init(variable: "SHELL", value: "/bin/\(shell)"),
|
||||
.init(variable: "HISTFILE", value: path),
|
||||
.init(variable: "XDG_DATA_HOME", value: root),
|
||||
.init(variable: "fish_history", value: "manual"),
|
||||
])
|
||||
let reference = RemoteSessionReference(
|
||||
backend: .tmux, session: session, hostID: UUID(), endpoint: "fixture", username: "root",
|
||||
ownershipToken: owner)
|
||||
state.reference = reference
|
||||
stage = "manual attach history"
|
||||
await channel.write(Data("tmux attach-session -t '\(session.id)'\n".utf8))
|
||||
try await wait { state.coordinator.status == .multiplexer && state.coordinator.active?.promptReady == true }
|
||||
#expect(state.coordinator.directory == "/tmp")
|
||||
let pane = try #require(state.coordinator.active)
|
||||
#expect(pane.historySource?.path == path)
|
||||
let paneHistory = RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: pane)
|
||||
#expect(try await paneHistory.load(source: nil).map(\.command) == ["echo manual-pane"])
|
||||
if outer.historySource?.path == "" {
|
||||
#expect(try await outerHistory.load(source: nil).isEmpty)
|
||||
} else {
|
||||
await #expect(throws: TerminalRuntimeError.self) { try await outerHistory.load(source: nil) }
|
||||
}
|
||||
await channel.write(Data([0x02, 0x64]))
|
||||
stage = "detach history"
|
||||
try await wait { state.coordinator.status == .ordinary }
|
||||
#expect(state.coordinator.active?.identity == outer.identity)
|
||||
_ = try await outerHistory.load(source: nil)
|
||||
await #expect(throws: TerminalRuntimeError.self) { try await paneHistory.load(source: nil) }
|
||||
#expect(
|
||||
state.events.contains {
|
||||
if case .commandFinished = $0.payload { return $0.context == outer.identity }
|
||||
return false
|
||||
})
|
||||
_ = try await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
||||
try await cleanup(channel, state)
|
||||
} catch {
|
||||
Issue.record("Manual history acceptance failed at \(stage) for \(shell): \(error)")
|
||||
if let execute = try? await channel.sessionCommandExecutor() {
|
||||
_ = try? await execute("rm -rf \(try RemoteTmuxCommands.quote(root))")
|
||||
}
|
||||
try? await cleanup(channel, state)
|
||||
throw error
|
||||
}
|
||||
@@ -254,6 +354,11 @@ struct TerminalContextRuntimeTests {
|
||||
_ = try await channel.contextDraft(context)
|
||||
Issue.record("The occupied channel budget must refuse a draft exec")
|
||||
} catch {}
|
||||
do {
|
||||
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8, context: context)
|
||||
.load(source: nil)
|
||||
Issue.record("The occupied channel budget must refuse a history exec")
|
||||
} catch {}
|
||||
await channel.write(Data("printf 'TERMINAL_STILL_WORKS\\n'\n".utf8))
|
||||
try await wait { String(decoding: state.output, as: UTF8.self).contains("TERMINAL_STILL_WORKS\r\n") }
|
||||
await channel.disconnect()
|
||||
@@ -284,6 +389,9 @@ struct TerminalContextRuntimeTests {
|
||||
try await wait { state.coordinator.status == .unavailable }
|
||||
#expect(state.coordinator.directory == nil)
|
||||
#expect(state.coordinator.ticket(requiring: .replaceDraft) == nil)
|
||||
await #expect(throws: TerminalRuntimeError.self) {
|
||||
try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
||||
}
|
||||
await channel.write(Data("printf 'SERVER_LIMIT_%s\\n' TERMINAL_OK\n".utf8))
|
||||
try await wait { String(decoding: state.output, as: UTF8.self).contains("SERVER_LIMIT_TERMINAL_OK") }
|
||||
try await cleanup(channel, state)
|
||||
@@ -293,6 +401,23 @@ struct TerminalContextRuntimeTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1)), arguments: [false, true])
|
||||
func plainShellHistoryStillLoadsWithoutIntegration(prepared: Bool) async throws {
|
||||
let fixture = try loadFixture()
|
||||
guard (fixture.maxSessions ?? 1) > 1 else { return }
|
||||
let state = State()
|
||||
let channel = makeChannel(state)
|
||||
if prepared { await channel.configureStartup { _ in nil } }
|
||||
do {
|
||||
try await connect(channel, fixture, shellIntegrationEnabled: false)
|
||||
_ = try await RemoteShellHistoryLoader(channel: channel, charset: .utf8).load(source: nil)
|
||||
await channel.disconnect()
|
||||
} catch {
|
||||
await channel.disconnect()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.timeLimit(.minutes(1)))
|
||||
func loginScriptAutoAttachNeverReceivesBootstrapInput() async throws {
|
||||
let fixture = try loadFixture()
|
||||
@@ -353,6 +478,14 @@ struct TerminalContextRuntimeTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func historyContents(_ command: String, shell: String) -> String {
|
||||
switch shell {
|
||||
case "zsh": ": 123:0;\(command)\n"
|
||||
case "fish": "- cmd: \(command)\n when: 123\n"
|
||||
default: "#123\n\(command)\n"
|
||||
}
|
||||
}
|
||||
|
||||
private func makeChannel(_ state: State) -> SSHPTYChannel {
|
||||
SSHPTYChannel(
|
||||
output: { data in Task { @MainActor in state.output.append(data) } }, exit: { _ in },
|
||||
@@ -381,7 +514,9 @@ struct TerminalContextRuntimeTests {
|
||||
throw TerminalRuntimeError.invalidResponse
|
||||
}
|
||||
|
||||
private func connect(_ channel: SSHPTYChannel, _ fixture: Fixture) async throws {
|
||||
private func connect(
|
||||
_ channel: SSHPTYChannel, _ fixture: Fixture, shellIntegrationEnabled: Bool = true
|
||||
) async throws {
|
||||
let key = try String(contentsOfFile: fixture.privateKeyPath, encoding: .utf8)
|
||||
try await channel.connect(
|
||||
using: SSHConnectionRequest(
|
||||
@@ -389,6 +524,6 @@ struct TerminalContextRuntimeTests {
|
||||
hostID: UUID(), host: "127.0.0.1", port: fixture.port, username: "root",
|
||||
credentials: [.privateKey(privateKey: SSHSecret(key), publicKey: nil, passphrase: nil)],
|
||||
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.hostFingerprint)])),
|
||||
shellIntegrationEnabled: true, columns: 100, rows: 30)
|
||||
shellIntegrationEnabled: shellIntegrationEnabled, columns: 100, rows: 30)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user