Add Host startup snippets
This commit is contained in:
1 parent
b69881eb41
commit
8a9793eadb
18 files changed
+435
-23
No files matched your search
@@ -13,7 +13,10 @@ struct HostShellIntegrationTests {
|
||||
func overridesResolveAndRoundTrip(mode: HostShellIntegrationMode, global: Bool) throws {
|
||||
let expected = mode == .enabled || (mode == .followGlobalSettings && global)
|
||||
#expect(mode.isEnabled(globalSetting: global) == expected)
|
||||
let host = Host(name: "Server", host: "server.invalid", shellIntegrationMode: mode)
|
||||
let host = Host(
|
||||
name: "Server", host: "server.invalid", shellIntegrationMode: mode,
|
||||
startupSnippetID: UUID()
|
||||
)
|
||||
#expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host)
|
||||
#expect(PersistentHost(host).value == host)
|
||||
}
|
||||
@@ -26,6 +29,11 @@ struct HostShellIntegrationTests {
|
||||
let decoded = try JSONDecoder().decode(Host.self, from: JSONSerialization.data(withJSONObject: json))
|
||||
#expect(decoded == host)
|
||||
}
|
||||
json.removeValue(forKey: "startupSnippetID")
|
||||
let decoded = try JSONDecoder().decode(
|
||||
Host.self, from: JSONSerialization.data(withJSONObject: json)
|
||||
)
|
||||
#expect(decoded.startupSnippetID == nil)
|
||||
}
|
||||
|
||||
@Test func savedModesSyncAndCanReturnToFollowingGlobalSettings() throws {
|
||||
@@ -35,7 +43,9 @@ struct HostShellIntegrationTests {
|
||||
let destination = HostPersistence(context: destinationContainer.mainContext)
|
||||
let library = HostLibraryModel(persistence: source)
|
||||
let journal = VaultSyncJournal(context: sourceContainer.mainContext)
|
||||
var host = Host(name: "Server", host: "server.invalid")
|
||||
var host = Host(
|
||||
name: "Server", host: "server.invalid", startupSnippetID: UUID()
|
||||
)
|
||||
library.upsert(host)
|
||||
host = try #require(library.host(host.id))
|
||||
let key = VaultRecordKey(kind: .host, id: host.id)
|
||||
|
||||
@@ -69,13 +69,46 @@ struct PersistenceCompatibilityTests {
|
||||
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV23.versionIdentifier })
|
||||
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV24.versionIdentifier })
|
||||
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV25.versionIdentifier })
|
||||
#expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier })
|
||||
#expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self })
|
||||
#expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
|
||||
#expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
|
||||
#expect(!TermindSchemaV26.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self })
|
||||
#expect(TermindSchemaV26.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self })
|
||||
#expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" })
|
||||
#expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" })
|
||||
#expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" })
|
||||
}
|
||||
|
||||
@Test
|
||||
func version25StoreMigratesWithNoStartupSnippet() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("Termind.store")
|
||||
let oldSchema = Schema(versionedSchema: TermindSchemaV25.self)
|
||||
let oldConfiguration = ModelConfiguration(
|
||||
"Termind", schema: oldSchema, url: url, cloudKitDatabase: .none
|
||||
)
|
||||
var oldContainer: ModelContainer? = try ModelContainer(
|
||||
for: oldSchema, configurations: [oldConfiguration]
|
||||
)
|
||||
oldContainer?.mainContext.insert(
|
||||
TermindSchemaV24HostModels.PersistentHost(
|
||||
Host(name: "Before startup snippets", host: "server.invalid")
|
||||
)
|
||||
)
|
||||
try oldContainer?.mainContext.save()
|
||||
oldContainer = nil
|
||||
|
||||
let migrated = try TermindPersistence.makeContainer(url: url)
|
||||
let host = try #require(
|
||||
migrated.mainContext.fetch(FetchDescriptor<PersistentHost>()).first?.value
|
||||
)
|
||||
#expect(host.name == "Before startup snippets")
|
||||
#expect(host.startupSnippetID == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
@@ -23983,6 +23983,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Startup Snippet" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "启动片段"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "啟動片段"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Status" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
|
||||
@@ -9,6 +9,7 @@ import struct TermindDomain.Host
|
||||
@MainActor
|
||||
public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
func host(_ id: Host.ID) -> Host?
|
||||
func snippet(_ id: Snippet.ID) -> Snippet?
|
||||
func identity(_ id: Identity.ID) -> Identity?
|
||||
func identity(for host: Host) -> Identity?
|
||||
func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity?
|
||||
@@ -68,6 +69,7 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
|
||||
}
|
||||
|
||||
extension RemoteSessionHostStore {
|
||||
public func snippet(_ id: Snippet.ID) -> Snippet? { nil }
|
||||
public func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? { nil }
|
||||
public var customPasswordStorage: PasswordStorage { .iCloud }
|
||||
|
||||
|
||||
@@ -1699,6 +1699,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
|
||||
public func delete(snippetIDs: [Snippet.ID]) {
|
||||
let doomed = Set(snippetIDs)
|
||||
clearStartupSnippetReferences(to: doomed)
|
||||
snippets.removeAll { doomed.contains($0.id) }
|
||||
persistCore()
|
||||
}
|
||||
@@ -1734,13 +1735,24 @@ public final class HostLibraryModel: RemoteSessionHostStore {
|
||||
snippets[index].sortIndex = nextSnippetSortIndex(in: nil)
|
||||
}
|
||||
} else {
|
||||
snippets.removeAll { $0.packageID.map(doomed.contains) == true }
|
||||
let doomedSnippetIDs = Set(
|
||||
snippets.lazy.filter { $0.packageID.map(doomed.contains) == true }.map(\.id)
|
||||
)
|
||||
clearStartupSnippetReferences(to: doomedSnippetIDs)
|
||||
snippets.removeAll { doomedSnippetIDs.contains($0.id) }
|
||||
}
|
||||
snippetPackages.removeAll { doomed.contains($0.id) }
|
||||
reconcileExpansionState()
|
||||
persistCore()
|
||||
}
|
||||
|
||||
private func clearStartupSnippetReferences(to snippetIDs: Set<Snippet.ID>) {
|
||||
guard !snippetIDs.isEmpty else { return }
|
||||
for index in hosts.indices where hosts[index].startupSnippetID.map(snippetIDs.contains) == true {
|
||||
hosts[index].startupSnippetID = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func move(
|
||||
snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?,
|
||||
before next: Snippet.ID? = nil
|
||||
|
||||
@@ -90,6 +90,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
public var tags: [String] = []
|
||||
public var colorTag: TagColor = .none
|
||||
public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings
|
||||
/// A reusable script submitted after each interactive SSH shell becomes ready.
|
||||
/// The body is resolved from the Snippet library for every connection attempt.
|
||||
public var startupSnippetID: Snippet.ID?
|
||||
/// Physical SSH connections available to local and dynamic forwarding on supported platforms.
|
||||
public var portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections
|
||||
/// Last distribution reported by Shell Integration for this saved Host.
|
||||
@@ -145,6 +148,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
groupID: HostGroup.ID? = nil, tags: [String] = [], colorTag: TagColor = .none,
|
||||
detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0,
|
||||
shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings,
|
||||
startupSnippetID: Snippet.ID? = nil,
|
||||
portForwardingMaxConnections: Int = Self.defaultPortForwardingMaxConnections
|
||||
) {
|
||||
self.id = id
|
||||
@@ -161,6 +165,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
self.detectedDistribution = detectedDistribution
|
||||
self.sortIndex = sortIndex
|
||||
self.shellIntegrationMode = shellIntegrationMode
|
||||
self.startupSnippetID = startupSnippetID
|
||||
self.portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections(
|
||||
portForwardingMaxConnections)
|
||||
}
|
||||
@@ -169,7 +174,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
case id, name, host, port, identityID, jumpHostID, proxy
|
||||
case sftpChannelInitialPath = "sftpSessionInitialPath"
|
||||
case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode
|
||||
case portForwardingMaxConnections
|
||||
case startupSnippetID, portForwardingMaxConnections
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
@@ -190,6 +195,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
|
||||
shellIntegrationMode =
|
||||
try values.decodeIfPresent(String.self, forKey: .shellIntegrationMode)
|
||||
.flatMap(HostShellIntegrationMode.init(rawValue:)) ?? .followGlobalSettings
|
||||
startupSnippetID = try values.decodeIfPresent(Snippet.ID.self, forKey: .startupSnippetID)
|
||||
portForwardingMaxConnections = Self.normalizedPortForwardingMaxConnections(
|
||||
try values.decodeIfPresent(Int.self, forKey: .portForwardingMaxConnections)
|
||||
?? Self.defaultPortForwardingMaxConnections)
|
||||
|
||||
@@ -746,6 +746,15 @@ public enum TermindSchemaV25: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindSchemaV26: VersionedSchema {
|
||||
public static let versionIdentifier = Schema.Version(26, 0, 0)
|
||||
public static var models: [any PersistentModel.Type] {
|
||||
TermindSchemaV25.models.filter {
|
||||
$0 != TermindSchemaV24HostModels.PersistentHost.self
|
||||
} + [TermindSchemaV26HostModels.PersistentHost.self]
|
||||
}
|
||||
}
|
||||
|
||||
public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
public static var schemas: [any VersionedSchema.Type] {
|
||||
[
|
||||
@@ -756,6 +765,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self,
|
||||
TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self,
|
||||
TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self,
|
||||
TermindSchemaV26.self,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -822,10 +832,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV22.self, toVersion: TermindSchemaV23.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV23.self, toVersion: TermindSchemaV24.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self),
|
||||
MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public typealias PersistentHost = TermindSchemaV24HostModels.PersistentHost
|
||||
public typealias PersistentHost = TermindSchemaV26HostModels.PersistentHost
|
||||
public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity
|
||||
public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey
|
||||
@@ -5,7 +5,7 @@ import SwiftData
|
||||
/// choose storage and own the container lifetime, never the model set.
|
||||
@MainActor
|
||||
public enum TermindPersistence {
|
||||
public static let schema = Schema(versionedSchema: TermindSchemaV25.self)
|
||||
public static let schema = Schema(versionedSchema: TermindSchemaV26.self)
|
||||
|
||||
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
|
||||
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import TermindDomain
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
/// V26 adds the optional Startup Snippet reference while preserving the Host entity name.
|
||||
public enum TermindSchemaV26HostModels {
|
||||
@Model
|
||||
public final class PersistentHost {
|
||||
@Attribute(.unique) public var id: UUID = UUID()
|
||||
public var name = ""
|
||||
public var address = ""
|
||||
public var port = 22
|
||||
public var identityID: UUID?
|
||||
public var jumpHostID: UUID?
|
||||
public var proxyProtocol: String?
|
||||
public var proxyHostname = ""
|
||||
public var proxyPort = 0
|
||||
public var proxyUsername = ""
|
||||
public var proxyIdentityID: UUID?
|
||||
public var sftpSessionInitialPath = ""
|
||||
public var groupID: UUID?
|
||||
public var tags: [String] = []
|
||||
public var colorTag = TagColor.none.rawValue
|
||||
public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue
|
||||
public var startupSnippetID: UUID?
|
||||
public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections
|
||||
public var detectedDistributionID: String?
|
||||
public var detectedDistributionLikeIDs: [String] = []
|
||||
public var lastUsedAt: Date?
|
||||
public var sortIndex = 0.0
|
||||
|
||||
public init(_ value: Host) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
public func apply(_ value: Host) {
|
||||
name = value.name
|
||||
address = value.host
|
||||
port = value.port
|
||||
identityID = value.identityID
|
||||
jumpHostID = value.jumpHostID
|
||||
proxyProtocol = value.proxy?.kind.rawValue
|
||||
proxyHostname = value.proxy?.hostname ?? ""
|
||||
proxyPort = value.proxy?.port ?? 0
|
||||
proxyUsername = value.proxy?.username ?? ""
|
||||
proxyIdentityID = value.proxy?.identityID
|
||||
sftpSessionInitialPath = value.sftpChannelInitialPath
|
||||
groupID = value.groupID
|
||||
tags = value.tags
|
||||
colorTag = value.colorTag.rawValue
|
||||
shellIntegrationMode = value.shellIntegrationMode.rawValue
|
||||
startupSnippetID = value.startupSnippetID
|
||||
portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections(
|
||||
value.portForwardingMaxConnections)
|
||||
detectedDistributionID = value.detectedDistribution?.id
|
||||
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
public var value: Host {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
detectedDistribution: detectedDistributionID.flatMap {
|
||||
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
|
||||
},
|
||||
sortIndex: sortIndex,
|
||||
shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode)
|
||||
?? .followGlobalSettings,
|
||||
startupSnippetID: startupSnippetID,
|
||||
portForwardingMaxConnections: portForwardingMaxConnections
|
||||
?? Host.defaultPortForwardingMaxConnections
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
host.sftpChannelInitialPath = sftpSessionInitialPath
|
||||
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
|
||||
host.proxy = HostProxy(
|
||||
kind: kind, hostname: proxyHostname, port: proxyPort,
|
||||
username: proxyUsername, identityID: proxyIdentityID
|
||||
)
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,21 @@ import TermindDomain
|
||||
|
||||
public struct HostIntegrationSection: View {
|
||||
@Binding private var mode: HostShellIntegrationMode
|
||||
@Binding private var startupSnippetID: Snippet.ID?
|
||||
private let snippets: [Snippet]
|
||||
private let snippetPackagePath: (SnippetPackage.ID?) -> String?
|
||||
|
||||
public init(mode: Binding<HostShellIntegrationMode>) { _mode = mode }
|
||||
public init(
|
||||
mode: Binding<HostShellIntegrationMode>,
|
||||
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
|
||||
snippets: [Snippet] = [],
|
||||
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil }
|
||||
) {
|
||||
_mode = mode
|
||||
_startupSnippetID = startupSnippetID
|
||||
self.snippets = snippets
|
||||
self.snippetPackagePath = snippetPackagePath
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Section("Integration") {
|
||||
@@ -15,6 +28,25 @@ public struct HostIntegrationSection: View {
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
|
||||
Picker("Startup Snippet", selection: $startupSnippetID) {
|
||||
Text("None").tag(Snippet.ID?.none)
|
||||
if !snippets.isEmpty {
|
||||
Divider()
|
||||
ForEach(snippets) { snippet in
|
||||
Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.menuOrder(.fixed)
|
||||
}
|
||||
}
|
||||
|
||||
private func snippetLabel(_ snippet: Snippet) -> String {
|
||||
guard let path = snippetPackagePath(snippet.packageID), !path.isEmpty else {
|
||||
return snippet.title
|
||||
}
|
||||
return "\(path) / \(snippet.title)"
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,9 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
|
||||
override/global once before auth for bootstrap/History/generator/system info/directory actions. Live edits never inject or
|
||||
reconnect; new reconnects resolve again. Mobile integration/exit uses one ordered MainActor consumer retired on close/
|
||||
reconnect/expiration. Prompt markers are idempotent; Bash DEBUG excludes `__termind_*` helpers from History/busy state.
|
||||
- Host Startup Snippet is a Vault-synced optional Snippet ID (V26). Every interactive SSH shell resolves the current body and
|
||||
submits it once at the authenticated prompt boundary, including automatic reconnects; explicit launch commands override only
|
||||
until submitted. Missing/empty Snippets are no-ops. SFTP, forwarding, Metrics, Agent and route connections never run it.
|
||||
- History starts after authenticated start, retains optional remote status/duration (never receive-time inference), normalizes
|
||||
backslash continuation only and caps at 1,000 rows/Host. Both UIs show first line plus ellipsis.
|
||||
|
||||
|
||||
@@ -175,7 +175,12 @@ struct MobileHostEditor: View {
|
||||
MobileTagField(tags: $draft.tags, suggestions: library.allTags)
|
||||
}
|
||||
}
|
||||
HostIntegrationSection(mode: $draft.shellIntegrationMode)
|
||||
HostIntegrationSection(
|
||||
mode: $draft.shellIntegrationMode,
|
||||
startupSnippetID: startupSnippetChoice,
|
||||
snippets: library.snippets,
|
||||
snippetPackagePath: library.snippetPackagePath
|
||||
)
|
||||
}
|
||||
)
|
||||
.sheet(item: $editorRoute) { route in
|
||||
@@ -234,6 +239,14 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var startupSnippetChoice: Binding<Snippet.ID?> {
|
||||
Binding {
|
||||
draft.startupSnippetID.flatMap { library.snippet($0)?.id }
|
||||
} set: {
|
||||
draft.startupSnippetID = $0
|
||||
}
|
||||
}
|
||||
|
||||
private var proxyKind: Binding<HostProxy.ProtocolKind?> {
|
||||
Binding {
|
||||
draft.proxy?.kind
|
||||
|
||||
@@ -109,6 +109,8 @@ final class MobileWorkspace: Identifiable {
|
||||
private var shellIntegrationAvailable = false
|
||||
private var isInitialCommandSubmissionReady = false
|
||||
private var pendingCommand: MobilePendingInitialCommand?
|
||||
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
|
||||
private var pendingConnectionStartupCommand: MobilePendingInitialCommand?
|
||||
private var commandExcludedFromHistory: String?
|
||||
private var pendingInitialSFTPPath: String?
|
||||
var pendingInitialCommand: String? { pendingCommand?.command }
|
||||
@@ -505,6 +507,7 @@ final class MobileWorkspace: Identifiable {
|
||||
isShellPromptReady = false
|
||||
shellIntegrationAvailable = false
|
||||
isInitialCommandSubmissionReady = false
|
||||
pendingConnectionStartupCommand = nil
|
||||
workingDirectory = nil
|
||||
commandExcludedFromHistory = nil
|
||||
commandDraft.cancel()
|
||||
@@ -601,6 +604,15 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled(
|
||||
globalSetting: terminalSettings.preferences.shellIntegration)
|
||||
if descriptor.kind == .ssh, pendingCommand == nil,
|
||||
let snippetID = host.startupSnippetID,
|
||||
let body = hosts.snippet(snippetID)?.body,
|
||||
!body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
pendingConnectionStartupCommand = MobilePendingInitialCommand(
|
||||
command: body, recordsInHistory: true
|
||||
)
|
||||
}
|
||||
let route = hosts.route(for: host)
|
||||
journey.begin(
|
||||
host: host, route: route,
|
||||
@@ -824,17 +836,25 @@ final class MobileWorkspace: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the launch-only command before it is written. Reconnects and
|
||||
/// restored descriptors therefore cannot replay a script.
|
||||
/// Consumes the explicit launch command once, then the Startup Snippet that
|
||||
/// was freshly resolved for this physical terminal shell.
|
||||
func consumePendingInitialCommand() -> String? {
|
||||
guard let pendingCommand else { return nil }
|
||||
self.pendingCommand = nil
|
||||
if !pendingCommand.recordsInHistory {
|
||||
let command: MobilePendingInitialCommand
|
||||
if let explicit = pendingCommand {
|
||||
command = explicit
|
||||
pendingCommand = nil
|
||||
} else if let startup = pendingConnectionStartupCommand {
|
||||
command = startup
|
||||
pendingConnectionStartupCommand = nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if !command.recordsInHistory {
|
||||
commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations(
|
||||
in: pendingCommand.command
|
||||
in: command.command
|
||||
)
|
||||
}
|
||||
return pendingCommand.command
|
||||
return command.command
|
||||
}
|
||||
|
||||
private func submitPendingInitialCommandIfReady() {
|
||||
@@ -1014,6 +1034,7 @@ final class MobileWorkspace: Identifiable {
|
||||
|
||||
activeHistoryID = nil
|
||||
pendingCommand = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
pendingInitialSFTPPath = nil
|
||||
commandExcludedFromHistory = nil
|
||||
generation = UUID()
|
||||
|
||||
@@ -135,6 +135,72 @@ struct MobileCommandGeneratorTests {
|
||||
#expect(connections.shellIntegrationPreferences == [expected, !expected, expected])
|
||||
}
|
||||
|
||||
@Test func startupSnippetUsesExplicitLaunchOnceThenRefreshesOnReconnect() async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
var startup = Snippet(title: "Startup", body: "echo first")
|
||||
host.startupSnippetID = startup.id
|
||||
library.upsert(startup)
|
||||
library.upsert(host)
|
||||
let connections = MobileSSHCommandConnectionBox()
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
|
||||
hosts: library,
|
||||
connectionServices: connections.services,
|
||||
initialCommand: "echo explicit"
|
||||
)
|
||||
defer { workspace.disconnect() }
|
||||
|
||||
workspace.connect()
|
||||
#expect(await eventually { workspace.connected })
|
||||
#expect(connections.writes.isEmpty)
|
||||
await connections.sendShellEvents([
|
||||
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
|
||||
])
|
||||
#expect(await eventually { connections.writes == [Data("echo explicit\r".utf8)] })
|
||||
|
||||
startup.body = "echo latest"
|
||||
library.upsert(startup)
|
||||
await connections.finishSession(.failure(.socket("test disconnect")), session: 0)
|
||||
#expect(await eventually(attempts: 500) { connections.sessionCount == 2 && workspace.connected })
|
||||
await connections.sendShellEvents(
|
||||
[.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")],
|
||||
session: 1
|
||||
)
|
||||
#expect(
|
||||
await eventually {
|
||||
connections.writes == [
|
||||
Data("echo explicit\r".utf8),
|
||||
Data("echo latest\r".utf8),
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test func startupSnippetRunsImmediatelyWhenShellIntegrationIsDisabled() async throws {
|
||||
var (host, library) = try commandFixture()
|
||||
let startup = Snippet(title: "Startup", body: "export TERMIND_TEST=1")
|
||||
host.startupSnippetID = startup.id
|
||||
host.shellIntegrationMode = .disabled
|
||||
library.upsert(startup)
|
||||
library.upsert(host)
|
||||
let connections = MobileSSHCommandConnectionBox()
|
||||
let workspace = MobileWorkspace(
|
||||
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: .ssh),
|
||||
hosts: library,
|
||||
connectionServices: connections.services
|
||||
)
|
||||
defer { workspace.disconnect() }
|
||||
|
||||
workspace.connect()
|
||||
|
||||
#expect(await eventually { workspace.connected })
|
||||
#expect(
|
||||
await eventually {
|
||||
connections.writes == [Data("export TERMIND_TEST=1\r".utf8)]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test func readsTheCurrentDraftAndInsertsWithoutExecuting() async throws {
|
||||
let (host, library) = try commandFixture()
|
||||
let connections = MobileSSHCommandConnectionBox()
|
||||
|
||||
@@ -221,7 +221,12 @@ struct HostEditorView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HostIntegrationSection(mode: $draft.shellIntegrationMode)
|
||||
HostIntegrationSection(
|
||||
mode: $draft.shellIntegrationMode,
|
||||
startupSnippetID: startupSnippetChoice,
|
||||
snippets: app.hostLibrary.snippets,
|
||||
snippetPackagePath: app.hostLibrary.snippetPackagePath
|
||||
)
|
||||
|
||||
if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Section("SSH Command") {
|
||||
@@ -339,6 +344,14 @@ struct HostEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var startupSnippetChoice: Binding<Snippet.ID?> {
|
||||
Binding {
|
||||
draft.startupSnippetID.flatMap { app.hostLibrary.snippet($0)?.id }
|
||||
} set: {
|
||||
draft.startupSnippetID = $0
|
||||
}
|
||||
}
|
||||
|
||||
private var jumpHostChoices: [Host] {
|
||||
app.hostLibrary.hosts.filter { candidate in
|
||||
candidate.id != draft.id
|
||||
|
||||
@@ -257,6 +257,8 @@ final class SSHSessionModel: Identifiable {
|
||||
var recordsInHistory: Bool
|
||||
}
|
||||
private var pendingInitialCommand: PendingInitialCommand?
|
||||
/// Re-resolved from the Host and Snippet library for each physical terminal shell.
|
||||
private var pendingConnectionStartupCommand: PendingInitialCommand?
|
||||
private var commandExcludedFromHistory: String?
|
||||
private var isInitialCommandSubmissionReady = false
|
||||
|
||||
@@ -440,6 +442,7 @@ final class SSHSessionModel: Identifiable {
|
||||
isShellPromptReady = false
|
||||
shellIntegrationAvailable = false
|
||||
isInitialCommandSubmissionReady = false
|
||||
pendingConnectionStartupCommand = nil
|
||||
activeShellCommand = nil
|
||||
commandDraft.cancel()
|
||||
invalidateCommandGeneration()
|
||||
@@ -470,7 +473,6 @@ final class SSHSessionModel: Identifiable {
|
||||
ssh = nil
|
||||
guard let host = connectionHost(hostID, using: store) else {
|
||||
connection.stopRecovery()
|
||||
pendingInitialCommand = nil
|
||||
connectionAttemptID = nil
|
||||
status = .failed(String(localized: "The host is no longer available."))
|
||||
connectionJourney.fail(String(localized: "The host is no longer available."))
|
||||
@@ -478,6 +480,15 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
isShellIntegrationEnabled = host.shellIntegrationMode.isEnabled(
|
||||
globalSetting: DesktopSettingsKey.isShellIntegrationEnabled)
|
||||
if pendingInitialCommand == nil,
|
||||
let snippetID = host.startupSnippetID,
|
||||
let body = store.snippet(snippetID)?.body,
|
||||
!body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
pendingConnectionStartupCommand = PendingInitialCommand(
|
||||
command: body, recordsInHistory: true
|
||||
)
|
||||
}
|
||||
homeResolution = isShellIntegrationEnabled ? .pending : .unavailable
|
||||
let route = connectionRoute?.hosts ?? store.route(for: host)
|
||||
let identity = store.identity(for: host)
|
||||
@@ -660,7 +671,6 @@ final class SSHSessionModel: Identifiable {
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingInitialCommand = nil
|
||||
connectionAttemptID = nil
|
||||
handleConnectionAttemptFailure(
|
||||
error, store: store, reconnecting: reconnecting
|
||||
@@ -858,6 +868,7 @@ final class SSHSessionModel: Identifiable {
|
||||
homeResolution = .unavailable
|
||||
transferSnapshot = nil
|
||||
pendingInitialCommand = nil
|
||||
pendingConnectionStartupCommand = nil
|
||||
commandExcludedFromHistory = nil
|
||||
pendingAuthentication = nil
|
||||
suppliedAuthentications.removeAll()
|
||||
@@ -995,7 +1006,6 @@ final class SSHSessionModel: Identifiable {
|
||||
if connection.hasConnectedOnce || reconnecting {
|
||||
scheduleReconnect(after: error.localizedDescription, using: store)
|
||||
} else {
|
||||
pendingInitialCommand = nil
|
||||
status = .failed(error.localizedDescription)
|
||||
connectionJourney.fail(
|
||||
error.localizedDescription, at: (error as? TermindSSHError)?.routeHostID
|
||||
@@ -1263,11 +1273,20 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Submits the one-shot startup command. Keeping the History exclusion next
|
||||
/// to the write ensures it is armed only when the command actually runs.
|
||||
/// 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.
|
||||
func submitPendingInitialCommand() {
|
||||
guard let initialCommand = pendingInitialCommand else { return }
|
||||
pendingInitialCommand = nil
|
||||
let initialCommand: PendingInitialCommand
|
||||
if let explicit = pendingInitialCommand {
|
||||
initialCommand = explicit
|
||||
pendingInitialCommand = nil
|
||||
} else if let startup = pendingConnectionStartupCommand {
|
||||
initialCommand = startup
|
||||
pendingConnectionStartupCommand = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if !initialCommand.recordsInHistory {
|
||||
commandExcludedFromHistory = CommandHistoryEntry.normalizingLineContinuations(
|
||||
in: initialCommand.command
|
||||
|
||||
@@ -64,4 +64,38 @@ struct HostShellIntegrationSessionTests {
|
||||
session.connect(using: HostLibraryModel())
|
||||
#expect(!session.isShellIntegrationEnabled)
|
||||
}
|
||||
|
||||
@Test func startupSnippetUsesExplicitLaunchOnceAndResolvesLatestBodyOnReconnect() {
|
||||
var startup = Snippet(title: "Startup", body: "echo first")
|
||||
let host = Host(
|
||||
name: "Server", host: "server.invalid", startupSnippetID: startup.id
|
||||
)
|
||||
let library = HostLibraryModel(hosts: [host], snippets: [startup])
|
||||
let session = SSHSessionModel(
|
||||
hostID: host.id, title: host.name, initialCommand: "echo explicit"
|
||||
)
|
||||
var submitted = Data()
|
||||
session.inputSink = { submitted.append($0) }
|
||||
defer { session.disconnect() }
|
||||
|
||||
session.connect(using: library)
|
||||
session.status = .connected
|
||||
session.receiveShellIntegrationEvent(
|
||||
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
|
||||
)
|
||||
#expect(String(decoding: submitted, as: UTF8.self) == "echo explicit\r")
|
||||
|
||||
session.disconnect()
|
||||
startup.body = "echo latest"
|
||||
library.upsert(startup)
|
||||
session.connect(using: library)
|
||||
session.status = .connected
|
||||
session.receiveShellIntegrationEvent(
|
||||
.available(shell: "bash", systemInfo: nil, homeDirectory: "/home/test")
|
||||
)
|
||||
#expect(
|
||||
String(decoding: submitted, as: UTF8.self)
|
||||
== "echo explicit\recho latest\r"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,38 @@ struct SnippetFeatureTests {
|
||||
#expect(store.snippet(snippet.id)?.targetHostIDs == [kept.id])
|
||||
}
|
||||
|
||||
@Test
|
||||
func deletingSnippetsClearsOnlyMatchingHostStartupReferences() {
|
||||
let package = SnippetPackage(name: "Startup")
|
||||
let direct = Snippet(title: "Direct", body: "echo direct")
|
||||
let packaged = Snippet(
|
||||
title: "Packaged", body: "echo packaged", packageID: package.id
|
||||
)
|
||||
let retained = Snippet(title: "Retained", body: "echo retained")
|
||||
let first = Host(
|
||||
name: "First", host: "first.invalid", startupSnippetID: direct.id
|
||||
)
|
||||
let second = Host(
|
||||
name: "Second", host: "second.invalid", startupSnippetID: packaged.id
|
||||
)
|
||||
let third = Host(
|
||||
name: "Third", host: "third.invalid", startupSnippetID: retained.id
|
||||
)
|
||||
let store = HostLibraryModel(
|
||||
hosts: [first, second, third], snippets: [direct, packaged, retained],
|
||||
snippetPackages: [package]
|
||||
)
|
||||
|
||||
store.delete(snippetIDs: [direct.id])
|
||||
#expect(store.host(first.id)?.startupSnippetID == nil)
|
||||
#expect(store.host(second.id)?.startupSnippetID == packaged.id)
|
||||
#expect(store.host(third.id)?.startupSnippetID == retained.id)
|
||||
|
||||
store.delete(snippetPackageID: package.id, keepSnippets: false)
|
||||
#expect(store.host(second.id)?.startupSnippetID == nil)
|
||||
#expect(store.host(third.id)?.startupSnippetID == retained.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func snippetPersistenceRoundTripKeepsPackagesTargetsAndLocalExpansion() throws {
|
||||
let configuration = localModelConfiguration(
|
||||
|
||||
Reference in new issue
Block a user