Fix Snippet host execution
This commit is contained in:
1 parent
aca394a3a7
commit
445e54b6d9
15 files changed
+381
-62
No files matched your search
@@ -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 })) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user