Add Host startup snippets

This commit is contained in:
Kaede Akino committed 2026-09-21 16:48:50 +08:00
1 parent b69881eb41
commit 8a9793eadb
18 files changed
+435 -23

No files matched your search

+12 -2
View File
@@ -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
+7 -1
View File
@@ -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)"
}
}
+3
View File
@@ -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.
+14 -1
View File
@@ -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
+28 -7
View File
@@ -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()
+14 -1
View File
@@ -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
+26 -7
View File
@@ -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"
)
}
}
+32
View File
@@ -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(