diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 8056e1e2..cd30d4e3 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -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 })) { diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 91ef5aec..c5a82bc1 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -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 } diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 176deceb..8ce6e92c 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -65,8 +65,58 @@ struct MobileConnectionPicker: View { } } -struct MobileHostPicker: View { +struct MobileHostPickerRow: 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: View { @State private var model: MobileHostPickerModel let title: LocalizedStringKey let emptyDescription: LocalizedStringKey @@ -111,21 +161,9 @@ struct MobileHostPicker: 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: 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 { diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index f73512f1..2180765b 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -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 init( - app: MobileAppModel, selected: Set, actionTitle: LocalizedStringKey, - requiresSelection: Bool, save: @escaping ([Host.ID]) -> Void + app: MobileAppModel, selected: Set, 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) } } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 292c385d..9a631765 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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() } diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index d2853cca..5836348e 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -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 diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index c67c90a9..5e9e3403 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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( diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 584bd420..12e3316b 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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() diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index 4c289a5b..90152334 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -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: 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 = diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 871f7b45..be268680 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -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) } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 16d2cee6..97774533 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -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 { diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index dff32d0a..09912da9 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -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) diff --git a/TFITests/MobileSnippetTests.swift b/TFITests/MobileSnippetTests.swift index 20ab92ba..38ac9b3d 100644 --- a/TFITests/MobileSnippetTests.swift +++ b/TFITests/MobileSnippetTests.swift @@ -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( diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 8d450997..ec8ac2cc 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 { diff --git a/TFMTests/CommandHistoryTests.swift b/TFMTests/CommandHistoryTests.swift index e43348f2..8f37e842 100644 --- a/TFMTests/CommandHistoryTests.swift +++ b/TFMTests/CommandHistoryTests.swift @@ -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"))