Fix Snippet host execution

This commit is contained in:
Kaede Akino committed 2026-09-13 17:44:59 +08:00
1 parent aca394a3a7
commit 445e54b6d9
15 files changed
+381 -62

No files matched your search

+3 -2
View File
@@ -8,9 +8,9 @@ struct MobileRootView: View {
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
@State private var scene: MobileSceneModel
init(app: MobileAppModel) {
init(app: MobileAppModel, scene: MobileSceneModel? = nil) {
self.app = app
_scene = State(initialValue: MobileSceneModel(app: app))
_scene = State(initialValue: scene ?? MobileSceneModel(app: app))
}
var body: some View {
@@ -39,6 +39,7 @@ struct MobileRootView: View {
selectedFeature.makeView(app: app, scene: scene, surface: .padSidebar)
}
}
.id(scene.selectedDestination ?? .vault)
}
}
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
+29 -1
View File
@@ -13,7 +13,11 @@ import struct TermindDomain.Host
final class MobileSceneModel {
let id = UUID()
let assistant: MobileAssistantSceneModel
var selectedDestination: MobileDestination? = .vault
var selectedDestination: MobileDestination? = .vault {
didSet {
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
}
}
var workspaceID: MobileWorkspace.ID?
var isConnectionPickerPresented = false
var isWorkspaceUnavailable = false
@@ -22,6 +26,7 @@ final class MobileSceneModel {
var assistantLibraryRoute: MobileAssistantLibraryRoute?
@ObservationIgnored private let app: MobileAppModel
@ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID?
init(app: MobileAppModel) {
self.app = app
@@ -54,6 +59,7 @@ final class MobileSceneModel {
}
func openConnectionPicker() {
pendingWorkspaceID = nil
selectedDestination = .workspaces
workspaceID = nil
isConnectionPickerPresented = true
@@ -86,6 +92,23 @@ final class MobileSceneModel {
}
func selectWorkspace(_ id: MobileWorkspace.ID) {
guard app.workspaces.contains(where: { $0.id == id }) else { return }
if selectedDestination != .workspaces {
workspaceID = nil
pendingWorkspaceID = id
selectedDestination = .workspaces
return
}
presentWorkspace(id)
}
func activatePendingWorkspace() {
guard selectedDestination == .workspaces, let id = pendingWorkspaceID else { return }
pendingWorkspaceID = nil
presentWorkspace(id)
}
private func presentWorkspace(_ id: MobileWorkspace.ID) {
guard app.workspaces.contains(where: { $0.id == id }) else { return }
selectedDestination = .workspaces
workspaceID = id
@@ -145,6 +168,7 @@ final class MobileSceneModel {
func closeWorkspace(_ id: MobileWorkspace.ID) {
app.close(id)
if pendingWorkspaceID == id { pendingWorkspaceID = nil }
if workspaceID == id { workspaceID = nil }
}
@@ -155,10 +179,14 @@ final class MobileSceneModel {
func closeAllWorkspaces() {
app.closeAll()
pendingWorkspaceID = nil
workspaceID = nil
}
func reconcileWorkspaceIDs(_ workspaceIDs: [MobileWorkspace.ID]) {
if let pendingWorkspaceID, !workspaceIDs.contains(pendingWorkspaceID) {
self.pendingWorkspaceID = nil
}
guard let workspaceID, !workspaceIDs.contains(workspaceID) else { return }
self.workspaceID = nil
}
@@ -65,8 +65,58 @@ struct MobileConnectionPicker: View {
}
}
struct MobileHostPicker<Header: View>: View {
struct MobileHostPickerRow<Accessory: View>: View {
@Environment(\.colorScheme) private var colorScheme
let host: Host
let loginAddress: String
@ViewBuilder let accessory: Accessory
init(host: Host, loginAddress: String, @ViewBuilder accessory: () -> Accessory) {
self.host = host
self.loginAddress = loginAddress
self.accessory = accessory()
}
var body: some View {
HStack(spacing: 12) {
HostSystemIcon(asset: distributionEntry?.asset, size: 32)
.tint(tint)
VStack(alignment: .leading, spacing: 2) {
Text(host.name).foregroundStyle(.primary).lineLimit(1)
Text(loginAddress)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
accessory
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
private var distributionEntry: LinuxDistributionIconEntry? {
LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)
}
private var tint: Color {
host.colorTag.color
?? distributionEntry.flatMap {
LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme)
}
?? .accentColor
}
}
extension MobileHostPickerRow where Accessory == EmptyView {
init(host: Host, loginAddress: String) {
self.init(host: host, loginAddress: loginAddress) { EmptyView() }
}
}
struct MobileHostPicker<Header: View>: View {
@State private var model: MobileHostPickerModel
let title: LocalizedStringKey
let emptyDescription: LocalizedStringKey
@@ -111,21 +161,9 @@ struct MobileHostPicker<Header: View>: View {
Button {
select(host)
} label: {
HStack(spacing: 12) {
HostSystemIcon(asset: distributionEntry(for: host)?.asset, size: 32)
.tint(tint(for: host))
VStack(alignment: .leading, spacing: 2) {
Text(host.name).foregroundStyle(.primary).lineLimit(1)
Text(model.loginAddress(for: host))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
MobileHostPickerRow(
host: host,
loginAddress: model.loginAddress(for: host))
}
.buttonStyle(.plain)
}
@@ -142,18 +180,6 @@ struct MobileHostPicker<Header: View>: View {
}
}
}
private func distributionEntry(for host: Host) -> LinuxDistributionIconEntry? {
LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)
}
private func tint(for host: Host) -> Color {
host.colorTag.color
?? distributionEntry(for: host).flatMap {
LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme)
}
?? .accentColor
}
}
extension MobileHostPicker where Header == EmptyView {
+34 -19
View File
@@ -39,6 +39,7 @@ struct MobileSnippetsView: View {
@State private var deletingSnippetID: Snippet.ID?
@State private var deletingPackage: SnippetPackage?
@State private var targetRequest: TargetRequest?
@State private var pendingTargetExecution: ExecutionRequest?
@State private var destructiveRequest: ExecutionRequest?
init(
@@ -117,12 +118,12 @@ struct MobileSnippetsView: View {
package: request.packageID.flatMap(app.snippets.snippetPackage),
parentID: request.parentID)
}
.sheet(item: $targetRequest) { request in
.sheet(item: $targetRequest, onDismiss: completeTargetSelection) { request in
MobileSnippetHostPicker(
app: app, selected: [], actionTitle: "Execute", requiresSelection: true
app: app, selected: [], title: "Execute on Hosts", actionTitle: "Execute",
requiresSelection: true
) { ids in
targetRequest = nil
stageExecution(snippetID: request.snippetID, hostIDs: ids)
pendingTargetExecution = ExecutionRequest(snippetID: request.snippetID, hostIDs: ids)
}
}
.confirmationDialog(
@@ -355,6 +356,7 @@ struct MobileSnippetsView: View {
private func handle(_ decision: MobileSnippetExecutionDecision, snippetID: Snippet.ID) {
switch decision {
case .chooseHosts:
pendingTargetExecution = nil
targetRequest = TargetRequest(snippetID: snippetID)
case .confirmDestructive(let hostIDs):
destructiveRequest = ExecutionRequest(snippetID: snippetID, hostIDs: hostIDs)
@@ -376,6 +378,12 @@ struct MobileSnippetsView: View {
snippetID: snippetID)
}
private func completeTargetSelection() {
guard let request = pendingTargetExecution else { return }
pendingTargetExecution = nil
stageExecution(snippetID: request.snippetID, hostIDs: request.hostIDs)
}
private func performExecution(_ request: ExecutionRequest) {
guard let snippet = snippet(request.snippetID) else { return }
execute(snippet, request.hostIDs)
@@ -660,8 +668,8 @@ struct MobileSnippetEditor: View {
}
.sheet(isPresented: $choosingTargets) {
MobileSnippetHostPicker(
app: app, selected: Set(draft.targetHostIDs), actionTitle: "Done",
requiresSelection: false
app: app, selected: Set(draft.targetHostIDs), title: "Add Hosts",
actionTitle: "Done", requiresSelection: false
) { ids in
draft.targetHostIDs = ids
choosingTargets = false
@@ -752,6 +760,7 @@ struct MobileSnippetPackageEditor: View {
struct MobileSnippetHostPicker: View {
let app: MobileAppModel
let title: LocalizedStringKey
let actionTitle: LocalizedStringKey
let requiresSelection: Bool
let save: ([Host.ID]) -> Void
@@ -760,10 +769,12 @@ struct MobileSnippetHostPicker: View {
@State private var selected: Set<Host.ID>
init(
app: MobileAppModel, selected: Set<Host.ID>, actionTitle: LocalizedStringKey,
requiresSelection: Bool, save: @escaping ([Host.ID]) -> Void
app: MobileAppModel, selected: Set<Host.ID>, title: LocalizedStringKey,
actionTitle: LocalizedStringKey, requiresSelection: Bool,
save: @escaping ([Host.ID]) -> Void
) {
self.app = app
self.title = title
self.actionTitle = actionTitle
self.requiresSelection = requiresSelection
self.save = save
@@ -800,40 +811,44 @@ struct MobileSnippetHostPicker: View {
toggleAllResults()
}
}
Section("Hosts") {
Section {
ForEach(results) { host in
Button {
toggle(host.id)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(host.name).foregroundStyle(.primary)
Text(app.snippets.loginAddress(for: host))
.font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
MobileHostPickerRow(
host: host,
loginAddress: app.snippets.loginAddress(for: host)
) {
if selected.contains(host.id) {
Image(systemName: "checkmark").foregroundStyle(.tint)
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(.tint)
.accessibilityHidden(true)
}
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected.contains(host.id) ? [.isSelected] : [])
}
}
}
}
.searchable(text: $query, prompt: "Search hosts")
.navigationTitle("Add Hosts")
.navigationTitle(Text(title))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", systemImage: "xmark") { dismiss() }.labelStyle(.iconOnly)
}
ToolbarItem(placement: .confirmationAction) {
Button(actionTitle) {
Button(actionTitle, systemImage: "checkmark") {
let ordered = app.snippets.searchHosts("").filter { selected.contains($0.id) }.map(\.id)
save(ordered)
dismiss()
}
.labelStyle(.iconOnly)
.foregroundStyle(.tint)
.disabled(requiresSelection && selected.isEmpty)
}
}
+31 -8
View File
@@ -104,6 +104,7 @@ final class MobileWorkspace: Identifiable {
)
private var activeHistoryID: UUID?
private var shellIntegrationAvailable = false
private var isInitialCommandSubmissionReady = false
private var pendingCommand: MobilePendingInitialCommand?
private var commandExcludedFromHistory: String?
private var pendingInitialSFTPPath: String?
@@ -446,6 +447,7 @@ final class MobileWorkspace: Identifiable {
remoteSystemInfo = nil
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
workingDirectory = nil
commandExcludedFromHistory = nil
commandDraft.cancel()
@@ -471,18 +473,24 @@ final class MobileWorkspace: Identifiable {
// that authenticated boundary as ready even if the following
// TermindPrompt OSC is swallowed by the UIKit terminal path.
isShellPromptReady = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
recordRemoteSystemInfo(systemInfo)
submitPendingInitialCommandIfReady()
case .systemInfo(let systemInfo):
remoteSystemInfo = systemInfo
invalidateCommandGeneration()
recordRemoteSystemInfo(systemInfo)
case .homeResolutionUnavailable:
break
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .promptReady:
guard !isShellPromptReady else { return }
isShellPromptReady = true
invalidateCommandGeneration()
if !isShellPromptReady {
isShellPromptReady = true
invalidateCommandGeneration()
}
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .commandStarted(let command):
guard shellIntegrationAvailable else { return }
isShellPromptReady = false
@@ -501,6 +509,8 @@ final class MobileWorkspace: Identifiable {
isShellPromptReady = true
invalidateCommandGeneration()
}
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
guard let id = activeHistoryID else { return }
commandHistory.recordCompletion(
for: id, hostID: descriptor.hostID, exitStatus: status, duration: duration)
@@ -625,10 +635,11 @@ final class MobileWorkspace: Identifiable {
if wasReconnecting, descriptor.kind == .ssh {
appendTerminalNotice(String(localized: "Reconnected; a new remote shell has started."))
}
if descriptor.kind == .ssh, let session = ssh,
let command = consumePendingInitialCommand()
{
await connectionServices.writeSSH(session, Data((command + "\r").utf8))
if descriptor.kind == .ssh {
if !terminalSettings.preferences.shellIntegration {
isInitialCommandSubmissionReady = true
}
submitPendingInitialCommandIfReady()
}
} catch {
guard generation == token else { return }
@@ -704,6 +715,18 @@ final class MobileWorkspace: Identifiable {
return pendingCommand.command
}
private func submitPendingInitialCommandIfReady() {
guard descriptor.kind == .ssh, connected, isInitialCommandSubmissionReady,
let session = ssh, let command = consumePendingInitialCommand()
else { return }
let token = generation
let data = Data((command + "\r").utf8)
Task { [weak self] in
guard let self, generation == token, connected, ssh === session else { return }
await connectionServices.writeSSH(session, data)
}
}
func retryReconnectNow() {
connection.reconnect.retryNow()
}
@@ -83,6 +83,11 @@ struct MobileWorkspaceListView: View {
}
.mobileSheetPresentation(compact: true)
}
.task(id: scene.selectedDestination) {
guard scene.selectedDestination == .workspaces else { return }
await Task.yield()
scene.activatePendingWorkspace()
}
}
@ViewBuilder
+30
View File
@@ -721,6 +721,36 @@ struct MobileLiveActivitySchedulingTests {
@Suite(.serialized) @MainActor
struct MobileWorkspaceNavigationRenderingTests {
@Test func crossDestinationExecutionBuildsTheWorkspaceDetailInThePadRoot() async throws {
let connections = MobileSFTPConnectionBox(clients: [])
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let host = try configureConnectionFixture(in: app)
let scene = MobileSceneModel(app: app)
let snippet = Snippet(title: "Status", body: "uptime")
scene.execute(snippet, on: [host.id])
let workspace = try #require(app.workspaces.first)
defer { app.closeAll() }
#expect(app.renameWorkspace(workspace.id, to: "Snippet Workspace"))
#expect(scene.selectedDestination == .workspaces)
#expect(scene.workspaceID == nil)
try await withHostedView(
MobileRootView(app: app, scene: scene)
.environment(\.mobileInterfaceIdiom, .pad)
) { controller in
let navigation = try #require(
await navigationController(titled: "Snippet Workspace", in: controller)
)
let detail = try #require(navigation.topViewController)
#expect(scene.workspaceID == workspace.id)
#expect(descendant(of: MobileTerminalView.self, in: detail.view) != nil)
}
}
@Test func switchingFromSFTPRebuildsSSHContentAndConnectionJourney() async throws {
let connections = MobileSFTPConnectionBox(clients: [MobileTestSFTPClient()])
let app = try MobileAppModel(
@@ -207,7 +207,7 @@ struct MobileCommandGeneratorTests {
defer { app.close(id) }
#expect(await eventually { workspace.connected })
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
#expect(!connections.writes.contains(Data((command + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test"),
.commandStarted(command),
@@ -216,11 +216,44 @@ struct MobileCommandGeneratorTests {
.commandFinished(0, duration: 0.02),
])
#expect(await eventually { connections.writes.contains(Data((command + "\r").utf8)) })
#expect(await eventually { app.commandHistory.entries(for: host.id).count == 1 })
#expect(app.commandHistory.entries(for: host.id).first?.command == "pwd")
#expect(workspace.pendingInitialCommand == nil)
}
@Test func snippetStartupCommandWritesOnceAfterSSHConnect() async throws {
let connections = MobileSSHCommandConnectionBox()
let app = try MobileAppModel(
inMemory: true,
workspaceConnectionServices: connections.services
)
let identity = Identity(name: "Deploy", username: "deploy")
var host = Host(name: "Example", host: "example.invalid")
host.identityID = identity.id
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
app.library.upsert(host)
app.library.trust(
KnownHostEntry(
hostname: host.host,
port: host.port,
algorithm: .ed25519,
fingerprint: "SHA256:test"
))
let snippet = Snippet(title: "Status", body: "uptime")
let id = try #require(app.execute(snippet, on: [host.id]).first)
let workspace = try #require(app.workspaces.first(where: { $0.id == id }))
defer { app.close(id) }
#expect(await eventually { workspace.connected })
#expect(!connections.writes.contains(Data((snippet.body + "\r").utf8)))
await connections.sendShellEvents([
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test")
])
#expect(await eventually { connections.writes.contains(Data((snippet.body + "\r").utf8)) })
#expect(workspace.pendingInitialCommand == nil)
}
@Test func newTerminalInputCancelsAnInFlightGeneration() async throws {
let (host, library) = try commandFixture()
let connections = MobileSSHCommandConnectionBox()
@@ -165,6 +165,53 @@ struct MobileConnectionPickerTests {
}
}
@Test func snippetHostPickerUsesContextualTitlesAndCheckmarkConfirmation() async throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "Production", host: "production.example.invalid")
app.library.upsert(host)
try await withStandaloneNavigation(
MobileSnippetHostPicker(
app: app, selected: [], title: "Execute on Hosts", actionTitle: "Execute",
requiresSelection: true, save: { _ in }),
language: "en"
) { navigation in
#expect(navigation.topViewController?.navigationItem.title == "Execute on Hosts")
let execute = try #require(toolbarButton(named: "Execute", in: navigation))
#expect(!execute.isEnabled)
}
try await withStandaloneNavigation(
MobileSnippetHostPicker(
app: app, selected: [host.id], title: "Add Hosts", actionTitle: "Done",
requiresSelection: false, save: { _ in }),
language: "en"
) { navigation in
#expect(navigation.topViewController?.navigationItem.title == "Add Hosts")
let done = try #require(toolbarButton(named: "Done", in: navigation))
#expect(done.isEnabled)
}
}
@Test func snippetHostPickerSharesSingleSelectionRowsAndUsesATintedCheckmark() throws {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
let sharedPicker = try String(
contentsOf: root.appending(path: "TFI/Features/HostChooser/MobileConnectionPicker.swift"),
encoding: .utf8)
let snippetPicker = try String(
contentsOf: root.appending(path: "TFI/Features/Snippets/MobileSnippetsView.swift"),
encoding: .utf8)
#expect(sharedPicker.components(separatedBy: "MobileHostPickerRow(").count >= 2)
#expect(snippetPicker.contains("MobileHostPickerRow("))
#expect(snippetPicker.contains("Button(actionTitle, systemImage: \"checkmark\")"))
#expect(snippetPicker.contains(".labelStyle(.iconOnly)"))
#expect(snippetPicker.contains(".foregroundStyle(.tint)"))
#expect(snippetPicker.contains(".accessibilityAddTraits(selected.contains(host.id) ? [.isSelected] : [])"))
}
@Test(
arguments: [
("en", "Connect", "More"),
@@ -227,6 +274,29 @@ struct MobileConnectionPickerTests {
try check(navigation)
}
private func withStandaloneNavigation<Content: View>(
_ content: Content,
language: String,
check: (UINavigationController) throws -> Void
) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previousWindow = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: content.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
try check(navigation)
}
private func toolbarButton(named name: String, in navigation: UINavigationController) -> UIBarButtonItem? {
guard let item = navigation.topViewController?.navigationItem else { return nil }
let buttons =
+9 -2
View File
@@ -72,8 +72,15 @@ struct MobileIconRenderingTests {
}
try await capture("snippet-targets-\(suffix)", language: language, scheme: scheme) {
MobileSnippetHostPicker(
app: snippetApp, selected: [snippetHost.id], actionTitle: "Done",
requiresSelection: false, save: { _ in }
app: snippetApp, selected: [snippetHost.id], title: "Add Hosts",
actionTitle: "Done", requiresSelection: false, save: { _ in }
)
.environment(\.locale, locale)
}
try await capture("snippet-execution-targets-\(suffix)", language: language, scheme: scheme) {
MobileSnippetHostPicker(
app: snippetApp, selected: [], title: "Execute on Hosts",
actionTitle: "Execute", requiresSelection: true, save: { _ in }
)
.environment(\.locale, locale)
}
@@ -596,6 +596,8 @@ struct MobileNavigationParityTests {
let rootSource = try String(
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(!rootSource.contains(".navigationTitle(\"Termind\")"))
#expect(rootSource.contains("NavigationSplitView"))
#expect(rootSource.contains(".id(scene.selectedDestination ?? .vault)"))
}
@Test func vaultOnlyShowsMetricsWhenTheSidebarDoesNotProvideIt() throws {
+6
View File
@@ -84,11 +84,14 @@ struct MobileShortcutTests {
scene.openSettings()
scene.selectNextWorkspace()
#expect(scene.workspaceID == nil)
scene.activatePendingWorkspace()
#expect(scene.workspaceID == first)
#expect(scene.selectedDestination == .workspaces)
scene.openSettings()
scene.selectPreviousWorkspace()
scene.activatePendingWorkspace()
#expect(scene.workspaceID == third)
scene.selectNextWorkspace()
@@ -119,8 +122,10 @@ struct MobileShortcutTests {
let scene = MobileSceneModel(app: app)
let otherScene = MobileSceneModel(app: app)
otherScene.selectWorkspace(second)
otherScene.activatePendingWorkspace()
scene.selectWorkspace(first)
scene.activatePendingWorkspace()
#expect(scene.canCloseWorkspace)
scene.openSettings()
#expect(!scene.canCloseWorkspace)
@@ -128,6 +133,7 @@ struct MobileShortcutTests {
#expect(app.workspaces.map(\.id) == [first, second])
scene.selectWorkspace(first)
scene.activatePendingWorkspace()
scene.closeActiveWorkspace()
#expect(app.workspaces.map(\.id) == [second])
#expect(scene.workspaceID == nil)
+39
View File
@@ -153,6 +153,45 @@ struct MobileSnippetTests {
#expect(!app.pro.isPaywallPresented)
}
@Test func sceneExecutionSelectsTheFirstCreatedWorkspaceAndPreservesThePendingCommand() throws {
let app = try MobileAppModel(inMemory: true)
let first = Host(name: "First", host: "first.example.invalid")
let second = Host(name: "Second", host: "second.example.invalid")
app.library.upsert(first)
app.library.upsert(second)
let scene = MobileSceneModel(app: app)
let snippet = Snippet(title: "Status", body: "uptime")
scene.execute(snippet, on: [first.id, second.id])
defer { app.closeAll() }
#expect(scene.selectedDestination == .workspaces)
#expect(scene.workspaceID == nil)
scene.activatePendingWorkspace()
#expect(scene.workspaceID == app.workspaces.first?.id)
#expect(app.workspaces.map(\.pendingInitialCommand) == [snippet.body, snippet.body])
}
@Test func hostSelectionStagesExecutionOnlyAfterItsSheetDismisses() throws {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
let source = try String(
contentsOf: root.appending(path: "TFI/Features/Snippets/MobileSnippetsView.swift"),
encoding: .utf8)
let sheet = try #require(
source.components(
separatedBy: ".sheet(item: $targetRequest, onDismiss: completeTargetSelection)"
).last?.components(separatedBy: ".confirmationDialog(").first)
let completion = try #require(
source.components(separatedBy: "private func completeTargetSelection()").last?
.components(separatedBy: "private func performExecution").first)
#expect(sheet.contains("pendingTargetExecution = ExecutionRequest"))
#expect(!sheet.contains("stageExecution("))
#expect(completion.contains("stageExecution(snippetID: request.snippetID, hostIDs: request.hostIDs)"))
}
@Test func initialCommandIsLaunchOnlyAndConsumedAtMostOnce() throws {
let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Server", kind: .ssh)
let workspace = MobileWorkspace(
+17 -1
View File
@@ -233,6 +233,7 @@ final class SSHSessionModel: Identifiable {
}
private var pendingInitialCommand: PendingInitialCommand?
private var commandExcludedFromHistory: String?
private var isInitialCommandSubmissionReady = false
var reconnectState: ReconnectState? { connection.reconnectState }
@@ -391,6 +392,7 @@ final class SSHSessionModel: Identifiable {
remoteSystemInfo = nil
isShellPromptReady = false
shellIntegrationAvailable = false
isInitialCommandSubmissionReady = false
activeShellCommand = nil
commandDraft.cancel()
invalidateCommandGeneration()
@@ -847,7 +849,10 @@ final class SSHSessionModel: Identifiable {
String(localized: "Reconnected; a new remote shell has started.")
)
}
submitPendingInitialCommand()
if !DesktopSettingsKey.isShellIntegrationEnabled {
isInitialCommandSubmissionReady = true
}
submitPendingInitialCommandIfReady()
} catch {
await actor.disconnect()
if ssh === actor { ssh = nil }
@@ -1052,17 +1057,23 @@ final class SSHSessionModel: Identifiable {
statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID)
isShellPromptReady = false
shellIntegrationAvailable = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory)
submitPendingInitialCommandIfReady()
case .systemInfo(let systemInfo):
remoteSystemInfo = systemInfo
statusStore?.recordRemoteSystemInfo(systemInfo, for: hostID)
case .homeResolutionUnavailable:
homeResolution = .unavailable
inputRouter?.sshSession(self, didChangeWorkingDirectory: workingDirectory)
isInitialCommandSubmissionReady = true
submitPendingInitialCommandIfReady()
case .promptReady:
isShellPromptReady = true
isInitialCommandSubmissionReady = true
invalidateCommandGeneration()
submitPendingInitialCommandIfReady()
case .commandStarted(let command):
isShellPromptReady = false
invalidateCommandGeneration()
@@ -1087,6 +1098,11 @@ final class SSHSessionModel: Identifiable {
insert(initialCommand.command, submit: true)
}
private func submitPendingInitialCommandIfReady() {
guard status.isLive, isInitialCommandSubmissionReady else { return }
submitPendingInitialCommand()
}
@discardableResult
func recordExecutedCommand(_ command: String) -> CommandHistoryEntry.ID? {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+18
View File
@@ -182,6 +182,24 @@ struct CommandHistoryTests {
#expect(session.transientCommandHistory.map(\.command) == ["uptime"])
}
@Test
func startupCommandWaitsForTheShellBootstrapBeforeSubmitting() {
let session = SSHSessionModel(
host: Host(name: "Shell", host: "shell.example"),
initialCommand: "uptime"
)
var submitted = Data()
session.inputSink = { submitted.append($0) }
session.status = .connected
#expect(submitted.isEmpty)
session.receiveShellIntegrationEvent(
.available(shell: "zsh", systemInfo: nil, homeDirectory: "/home/test")
)
#expect(String(decoding: submitted, as: UTF8.self) == "uptime\r")
}
@Test
func shellLifecycleCompletesTheMatchingTransientHistoryRow() throws {
let session = SSHSessionModel(host: Host(name: "Shell", host: "shell.example"))