diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index d1547e1e..468a2b67 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -383,6 +383,19 @@ final class MobileAppModel { return workspace.id } + @discardableResult + func batchStartup( + hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol + ) -> [MobileWorkspace.ID] { + let wanted = Set(hostIDs) + return library.hosts(matching: "", for: connectionProtocol.useCase) + .filter { wanted.contains($0.id) } + .compactMap { host in + guard let kind = connectionProtocol.kind(for: host) else { return nil } + return open(hostID: host.id, kind: kind) + } + } + func openExternalConnection(_ request: RemoteConnectionURL) -> MobileWorkspace.ID { let host: Host let kind: ConnectionHistoryEntry.Kind diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index ea90fee0..384a2972 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -22,6 +22,7 @@ final class MobileSceneModel { } var workspaceID: MobileWorkspace.ID? var isConnectionPickerPresented = false + var isBatchStartupPresented = false var isWorkspaceUnavailable = false var pendingExternalConnection: RemoteConnectionURL? var editingHost: Host? @@ -31,6 +32,8 @@ final class MobileSceneModel { @ObservationIgnored private let app: MobileAppModel @ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID? + @ObservationIgnored private var pendingBatchStartup: + (hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol)? @ObservationIgnored private var recentWorkspaceIDs: [MobileWorkspace.ID] = [] init(app: MobileAppModel) { @@ -63,12 +66,38 @@ final class MobileSceneModel { } func openConnectionPicker() { + pendingBatchStartup = nil + isBatchStartupPresented = false pendingWorkspaceID = nil selectedDestination = .workspaces workspaceID = nil isConnectionPickerPresented = true } + func openBatchStartup() { + pendingWorkspaceID = nil + pendingBatchStartup = nil + selectedDestination = .workspaces + workspaceID = nil + isConnectionPickerPresented = false + isBatchStartupPresented = true + } + + func prepareBatchStartup(hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol) { + guard isBatchStartupPresented, pendingBatchStartup == nil, !hostIDs.isEmpty else { return } + pendingBatchStartup = (hostIDs, connectionProtocol) + } + + func completeBatchStartup() { + isBatchStartupPresented = false + guard let request = pendingBatchStartup else { return } + pendingBatchStartup = nil + pendingWorkspaceID = nil + selectedDestination = .workspaces + workspaceID = nil + app.batchStartup(hostIDs: request.hostIDs, connectionProtocol: request.connectionProtocol) + } + func openNewConversation() { assistant.newConversation() assistantLibraryRoute = nil diff --git a/TFI/Application/FeatureModels/MobileConnectionProtocol.swift b/TFI/Application/FeatureModels/MobileConnectionProtocol.swift new file mode 100644 index 00000000..e76e42fa --- /dev/null +++ b/TFI/Application/FeatureModels/MobileConnectionProtocol.swift @@ -0,0 +1,24 @@ +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +nonisolated enum MobileConnectionProtocol: Hashable { + case ssh + case sftp + + var useCase: HostUseCase { + switch self { + case .ssh: .preferredTerminal + case .sftp: .sftp + } + } + + func kind(for host: Host) -> ConnectionHistoryEntry.Kind? { + guard host.supports(useCase.protocolRequirement) else { return nil } + switch self { + case .ssh: return host.preferredTerminalProtocol == .telnet ? .telnet : .ssh + case .sftp: return .sftp + } + } +} diff --git a/TFI/Application/FeatureModels/MobileHostPickerModel.swift b/TFI/Application/FeatureModels/MobileHostPickerModel.swift index a39c759b..67acc2af 100644 --- a/TFI/Application/FeatureModels/MobileHostPickerModel.swift +++ b/TFI/Application/FeatureModels/MobileHostPickerModel.swift @@ -24,7 +24,7 @@ final class MobileHostPickerModel { @MainActor @Observable final class MobileConnectionPickerModel { - var kind = ConnectionHistoryEntry.Kind.ssh + var kind = MobileConnectionProtocol.ssh private let connect: (Host.ID, ConnectionHistoryEntry.Kind) -> Void @@ -33,12 +33,7 @@ final class MobileConnectionPickerModel { } func select(_ host: Host) { - let resolved: ConnectionHistoryEntry.Kind - if kind == .ssh, host.preferredTerminalProtocol == .telnet { - resolved = .telnet - } else { - resolved = kind - } + guard let resolved = kind.kind(for: host) else { return } connect(host.id, resolved) } } diff --git a/TFI/Application/FeatureModels/MobileMultiHostPickerModel.swift b/TFI/Application/FeatureModels/MobileMultiHostPickerModel.swift index 50f89a81..65347433 100644 --- a/TFI/Application/FeatureModels/MobileMultiHostPickerModel.swift +++ b/TFI/Application/FeatureModels/MobileMultiHostPickerModel.swift @@ -11,7 +11,7 @@ final class MobileMultiHostPickerModel { private(set) var selected: Set private let library: HostLibraryModel - private let useCase: HostUseCase + private var useCase: HostUseCase private let isEligible: (Host) -> Bool init( @@ -30,7 +30,15 @@ final class MobileMultiHostPickerModel { var orderedSelection: [Host.ID] { availableHostIDs.filter { selected.contains($0) } } var allResultsSelected: Bool { !hosts.isEmpty && hosts.allSatisfy { selected.contains($0.id) } } - func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } + func loginAddress(for host: Host) -> String { + if useCase == .preferredTerminal { return library.preferredTerminalLoginAddress(for: host) } + return library.loginAddress(for: host) + } + + func updateUseCase(_ useCase: HostUseCase) { + self.useCase = useCase + reconcileSelection() + } func reconcileSelection() { selected.formIntersection(availableHostIDs) } diff --git a/TFI/Features/HostChooser/MobileBatchStartupPicker.swift b/TFI/Features/HostChooser/MobileBatchStartupPicker.swift new file mode 100644 index 00000000..d9341cee --- /dev/null +++ b/TFI/Features/HostChooser/MobileBatchStartupPicker.swift @@ -0,0 +1,21 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation + +import struct TermindDomain.Host + +struct MobileBatchStartupPicker: View { + let library: HostLibraryModel + let connect: ([Host.ID], MobileConnectionProtocol) -> Void + @State private var connectionProtocol = MobileConnectionProtocol.ssh + + var body: some View { + MobileMultiHostPicker( + library: library, useCase: connectionProtocol.useCase, selected: [], + title: AppStrings.Vault.batchStartup, actionTitle: AppStrings.Common.connect, + emptyDescription: AppStrings.Vault.addAHostToConnectToIt, requiresSelection: true, + save: { connect($0, connectionProtocol) }, + header: { MobileConnectionProtocolPicker(selection: $connectionProtocol) }) + } +} diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 0344221a..cfc26497 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -9,6 +9,7 @@ struct MobileWorkspaceToolbar: ToolbarContent { let hasWorkspaces: Bool let closeAll: () -> Void let connect: () -> Void + let batchStartup: () -> Void var body: some ToolbarContent { ToolbarItem(placement: .primaryAction) { @@ -17,6 +18,8 @@ struct MobileWorkspaceToolbar: ToolbarContent { } .mobileProminentAction() MobileToolbarOverflow { + Button(AppStrings.Vault.batchStartup, systemImage: "wind", action: batchStartup) + Divider() Button(AppStrings.Vault.closeAll.swiftUIKey, systemImage: "xmark", role: .destructive) { closeAll() } @@ -47,20 +50,28 @@ struct MobileConnectionPicker: View { emptyDescription: AppStrings.Vault.addAHostToConnectToIt, cancel: cancel, select: model.select, - isEligible: { host in model.kind != .sftp || host.sshEnabled }, + isEligible: { model.kind.kind(for: $0) != nil }, header: { - Section { - Picker(AppStrings.Vault.connectionProtocol.swiftUIKey, selection: $model.kind) { - Text(AppStrings.Vault.ssh).tag(ConnectionHistoryEntry.Kind.ssh) - Text(AppStrings.SFTP.sftp).tag(ConnectionHistoryEntry.Kind.sftp) - } - .pickerStyle(.segmented) - } + MobileConnectionProtocolPicker(selection: $model.kind) } ) } } +struct MobileConnectionProtocolPicker: View { + @Binding var selection: MobileConnectionProtocol + + var body: some View { + Section { + Picker(AppStrings.Vault.connectionProtocol.swiftUIKey, selection: $selection) { + Text(AppStrings.Vault.ssh).tag(MobileConnectionProtocol.ssh) + Text(AppStrings.SFTP.sftp).tag(MobileConnectionProtocol.sftp) + } + .pickerStyle(.segmented) + } + } +} + struct MobileHostPickerRow: View { @Environment(\.colorScheme) private var colorScheme diff --git a/TFI/Features/HostChooser/MobileMultiHostPicker.swift b/TFI/Features/HostChooser/MobileMultiHostPicker.swift index c4535e06..b17b5f0a 100644 --- a/TFI/Features/HostChooser/MobileMultiHostPicker.swift +++ b/TFI/Features/HostChooser/MobileMultiHostPicker.swift @@ -5,12 +5,14 @@ import TermindPresentation import struct TermindDomain.Host -struct MobileMultiHostPicker: View { +struct MobileMultiHostPicker: View { let title: LocalizedStringResource let actionTitle: LocalizedStringResource let emptyDescription: LocalizedStringResource let requiresSelection: Bool let save: ([Host.ID]) -> Void + let useCase: HostUseCase + let header: Header @Environment(\.dismiss) private var dismiss @State private var model: MobileMultiHostPickerModel @@ -19,13 +21,16 @@ struct MobileMultiHostPicker: View { title: LocalizedStringResource, actionTitle: LocalizedStringResource, emptyDescription: LocalizedStringResource, requiresSelection: Bool, isEligible: @escaping (Host) -> Bool = { _ in true }, - save: @escaping ([Host.ID]) -> Void + save: @escaping ([Host.ID]) -> Void, + @ViewBuilder header: () -> Header ) { self.title = title self.actionTitle = actionTitle self.emptyDescription = emptyDescription self.requiresSelection = requiresSelection self.save = save + self.useCase = useCase + self.header = header() _model = State( initialValue: MobileMultiHostPickerModel( library: library, useCase: useCase, selected: selected, isEligible: isEligible)) @@ -34,6 +39,8 @@ struct MobileMultiHostPicker: View { var body: some View { NavigationStack { List { + header + if model.isLibraryEmpty { ContentUnavailableView( AppStrings.Vault.noHosts.swiftUIKey, systemImage: "server.rack", @@ -84,6 +91,7 @@ struct MobileMultiHostPicker: View { } ToolbarItem(placement: .confirmationAction) { Button(actionTitle, systemImage: "checkmark") { + model.updateUseCase(useCase) save(model.orderedSelection) dismiss() } @@ -93,7 +101,23 @@ struct MobileMultiHostPicker: View { } } .onChange(of: model.availableHostIDs, initial: true) { model.reconcileSelection() } + .onChange(of: useCase) { model.updateUseCase(useCase) } } .mobileSheetPresentation(compact: true) } } + +extension MobileMultiHostPicker where Header == EmptyView { + init( + library: HostLibraryModel, useCase: HostUseCase, selected: Set, + title: LocalizedStringResource, actionTitle: LocalizedStringResource, + emptyDescription: LocalizedStringResource, requiresSelection: Bool, + isEligible: @escaping (Host) -> Bool = { _ in true }, + save: @escaping ([Host.ID]) -> Void + ) { + self.init( + library: library, useCase: useCase, selected: selected, title: title, actionTitle: actionTitle, + emptyDescription: emptyDescription, requiresSelection: requiresSelection, + isEligible: isEligible, save: save, header: { EmptyView() }) + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 6e8b4b79..1eba6aa7 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -57,7 +57,8 @@ struct MobileWorkspaceListView: View { MobileWorkspaceToolbar( hasWorkspaces: scene.canCloseAllWorkspaces, closeAll: scene.closeAllWorkspaces, - connect: scene.openConnectionPicker + connect: scene.openConnectionPicker, + batchStartup: scene.openBatchStartup ) } .navigationDestination(item: $scene.workspaceID) { id in @@ -90,6 +91,9 @@ struct MobileWorkspaceListView: View { } .mobileSheetPresentation(compact: true) } + .sheet(isPresented: $scene.isBatchStartupPresented, onDismiss: scene.completeBatchStartup) { + MobileBatchStartupPicker(library: app.library, connect: scene.prepareBatchStartup) + } .task(id: scene.selectedDestination) { guard scene.selectedDestination == .workspaces else { return } await Task.yield() diff --git a/TFITests/MobileBatchStartupRenderingTests.swift b/TFITests/MobileBatchStartupRenderingTests.swift new file mode 100644 index 00000000..5c0c15ec --- /dev/null +++ b/TFITests/MobileBatchStartupRenderingTests.swift @@ -0,0 +1,169 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation +import Testing +import UIKit + +import struct TermindDomain.Host + +@testable import TermindIOS + +@Suite( + .enabled( + if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1", + "Set TERMIND_MOBILE_UI_TESTS=1 and disable parallel testing for key-window integration tests"), + .serialized +) +@MainActor +struct MobileBatchStartupRenderingTests { + @Test(arguments: [("en", "Batch Startup", "Connect"), ("zh-Hans", "批量启动", "连接"), ("zh-Hant", "批次啟動", "連線")]) + func workspacePresentsLocalizedBatchSheetAndResetsProtocolOnReopen( + language: String, title: String, connectTitle: String + ) async throws { + let app = try MobileAppModel(inMemory: true) + defer { app.closeAll() } + app.library.upsert(Host(name: "Production Gateway", host: "gateway.example.invalid")) + app.library.upsert(Host(name: "Staging Server", host: "staging.example.invalid")) + app.library.upsert( + Host(name: "Legacy Console", host: "console.example.invalid", sshEnabled: false, telnetEnabled: true)) + let scene = MobileSceneModel(app: app) + scene.selectedDestination = .workspaces + try await withWindow( + MobileRootView(app: app, scene: scene) + .environment(\.locale, Locale(identifier: language)) + ) { window, controller in + scene.openConnectionPicker() + try await Task.sleep(for: .milliseconds(800)) + let connectSheet = try #require(presented(in: controller)) + let connectFrame = try #require(connectSheet.presentationController?.frameOfPresentedViewInContainerView) + try capture(window, name: "connect-\(language)") + scene.isConnectionPickerPresented = false + try await Task.sleep(for: .milliseconds(700)) + scene.openBatchStartup() + try await Task.sleep(for: .milliseconds(800)) + let sheet = try #require(presented(in: controller)) + let batchFrame = try #require(sheet.presentationController?.frameOfPresentedViewInContainerView) + #expect(abs(batchFrame.midX - connectFrame.midX) < 2) + #expect(abs(batchFrame.midY - connectFrame.midY) < 2) + if UIDevice.current.userInterfaceIdiom == .pad { + #expect(abs(batchFrame.midY - window.bounds.midY) < 2) + } + let navigation = try #require(descendant(UINavigationController.self, in: sheet)) + #expect(navigation.topViewController?.navigationItem.title == title) + let connect = try #require(toolbarButton(named: connectTitle, in: navigation)) + #expect(!connect.isEnabled) + let segmented = try #require(descendants(UISegmentedControl.self, in: sheet.view).first) + #expect(segmented.numberOfSegments == 2) + #expect(segmented.selectedSegmentIndex == 0) + try capture(window, name: "batch-startup-\(language)-ssh") + segmented.selectedSegmentIndex = 1 + segmented.sendActions(for: .valueChanged) + try await Task.sleep(for: .milliseconds(300)) + #expect(segmented.selectedSegmentIndex == 1) + try capture(window, name: "batch-startup-\(language)-sftp") + scene.isBatchStartupPresented = false + try await Task.sleep(for: .milliseconds(700)) + #expect(app.workspaces.isEmpty) + scene.openBatchStartup() + try await Task.sleep(for: .milliseconds(700)) + let reopened = try #require(presented(in: controller)) + #expect(descendants(UISegmentedControl.self, in: reopened.view).first?.selectedSegmentIndex == 0) + scene.isBatchStartupPresented = false + try await Task.sleep(for: .milliseconds(500)) + } + } + + @Test func protocolChangeUpdatesMountedPickerAndPrunesOnlyIncompatibleSelections() async throws { + let ssh = Host(name: "Alpha", host: "alpha.invalid") + let telnet = Host(name: "Bravo", host: "bravo.invalid", sshEnabled: false, telnetEnabled: true) + let library = HostLibraryModel(hosts: [ssh, telnet]) + let route = BatchPickerRenderingRoute() + try await withWindow( + BatchPickerRenderingHost(library: library, selected: [telnet.id], route: route) + .environment(\.locale, Locale(identifier: "en")) + ) { _, controller in + let navigation = try #require(descendant(UINavigationController.self, in: controller)) + #expect(try #require(toolbarButton(named: "Connect", in: navigation)).isEnabled) + route.connectionProtocol = .sftp + try await Task.sleep(for: .milliseconds(300)) + #expect(try #require(toolbarButton(named: "Connect", in: navigation)).isEnabled == false) + route.connectionProtocol = .ssh + try await Task.sleep(for: .milliseconds(300)) + #expect(try #require(toolbarButton(named: "Connect", in: navigation)).isEnabled == false) + } + } + + private func withWindow( + _ content: Content, check: (UIWindow, UIViewController) async throws -> Void + ) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previous = scene.keyWindow + let window = UIWindow(windowScene: scene) + let controller = UIHostingController(rootView: content) + window.rootViewController = controller + window.windowLevel = .alert + 1 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previous?.makeKey() + } + try await Task.sleep(for: .milliseconds(700)) + try await check(window, controller) + } + + private func toolbarButton(named name: String, in navigation: UINavigationController) -> UIBarButtonItem? { + guard let item = navigation.topViewController?.navigationItem else { return nil } + let buttons = + item.leadingItemGroups.flatMap(\.barButtonItems) + item.trailingItemGroups.flatMap(\.barButtonItems) + + (item.leftBarButtonItems ?? []) + (item.rightBarButtonItems ?? []) + return buttons.first { + $0.accessibilityLabel == name || $0.title == name || $0.customView?.accessibilityLabel == name + } + } + + private func descendant(_ type: T.Type, in controller: UIViewController) -> T? { + if let result = controller as? T { return result } + return controller.children.lazy.compactMap { descendant(type, in: $0) }.first + } + + private func descendants(_ type: T.Type, in view: UIView) -> [T] { + (view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(type, in: $0) } + } + + private func presented(in controller: UIViewController) -> UIViewController? { + controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first + } + + private func capture(_ view: UIView, name: String) throws { + let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: true) + } + let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone" + Attachment.record(data, named: "\(device)-\(name).png") + let directory = FileManager.default.temporaryDirectory.appending(path: "termind-batch-startup") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "\(device)-\(name).png") + try data.write(to: url) + print("Batch Startup snapshot: \(url.path)") + } +} + +@Observable @MainActor private final class BatchPickerRenderingRoute { + var connectionProtocol = MobileConnectionProtocol.ssh +} + +private struct BatchPickerRenderingHost: View { + let library: HostLibraryModel + let selected: Set + @Bindable var route: BatchPickerRenderingRoute + + var body: some View { + MobileMultiHostPicker( + library: library, useCase: route.connectionProtocol.useCase, selected: selected, + title: AppStrings.Vault.batchStartup, actionTitle: AppStrings.Common.connect, + emptyDescription: AppStrings.Vault.addAHostToConnectToIt, requiresSelection: true, + save: { _ in }, header: { MobileConnectionProtocolPicker(selection: $route.connectionProtocol) }) + } +} diff --git a/TFITests/MobileBatchStartupTests.swift b/TFITests/MobileBatchStartupTests.swift new file mode 100644 index 00000000..da87a7dd --- /dev/null +++ b/TFITests/MobileBatchStartupTests.swift @@ -0,0 +1,144 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@testable import TermindIOS + +@Suite @MainActor +struct MobileBatchStartupTests { + @Test func connectionProtocolsResolveTheSameTargetsAsConnect() { + let ssh = Host(name: "SSH", host: "ssh.invalid") + let telnet = Host(name: "Telnet", host: "telnet.invalid", sshEnabled: false, telnetEnabled: true) + let both = Host(name: "Both", host: "both.invalid", telnetEnabled: true) + var disabled = ssh + disabled.sshEnabled = false + var selections: [ConnectionHistoryEntry.Kind] = [] + let connect = MobileConnectionPickerModel { _, kind in selections.append(kind) } + + #expect(connect.kind == .ssh) + for host in [ssh, telnet, both, disabled] { connect.select(host) } + #expect(selections == [.ssh, .telnet, .ssh]) + #expect(MobileConnectionProtocol.ssh.kind(for: disabled) == nil) + selections = [] + connect.kind = .sftp + for host in [ssh, telnet, both, disabled] { connect.select(host) } + #expect(selections == [.sftp, .sftp]) + } + + @Test func switchingProtocolsRetainsSearchAndOnlyCompatibleSelections() { + let ssh = Host(name: "Alpha", host: "ssh.invalid") + let telnet = Host( + name: "Bravo", host: "telnet.invalid", sshEnabled: false, telnetEnabled: true, telnetPort: 2323) + let library = HostLibraryModel(hosts: [ssh, telnet]) + let picker = MobileMultiHostPickerModel(library: library, useCase: .preferredTerminal, selected: []) + + picker.toggleAllResults() + #expect(picker.orderedSelection == [ssh.id, telnet.id]) + #expect(picker.loginAddress(for: telnet) == library.loginAddress(for: telnet, protocol: .telnet)) + picker.query = "Bravo" + picker.updateUseCase(.sftp) + #expect(picker.query == "Bravo") + #expect(picker.hosts.isEmpty) + #expect(picker.orderedSelection == [ssh.id]) + picker.updateUseCase(.preferredTerminal) + #expect(picker.hosts.map(\.id) == [telnet.id]) + #expect(picker.orderedSelection == [ssh.id]) + picker.toggleAllResults() + #expect(picker.orderedSelection == [ssh.id, telnet.id]) + picker.toggleAllResults() + #expect(picker.orderedSelection == [ssh.id]) + } + + @Test func batchCreatesIndependentOrderedWorkspacesWithHistoryAndRestoration() throws { + let app = try MobileAppModel(inMemory: true) + defer { app.closeAll() } + let alpha = Host(name: "Alpha", host: "alpha.invalid") + let bravo = Host(name: "Bravo", host: "bravo.invalid", sshEnabled: false, telnetEnabled: true) + let charlie = Host(name: "Charlie", host: "charlie.invalid") + for host in [charlie, alpha, bravo] { app.library.upsert(host) } + let existing = try #require(app.open(hostID: alpha.id, kind: .ssh)) + + let ids = app.batchStartup( + hostIDs: [charlie.id, bravo.id, alpha.id, alpha.id, UUID()], connectionProtocol: .ssh) + let added = ids.compactMap { id in app.workspaces.first { $0.id == id } } + + #expect(added.map(\.descriptor.hostID) == [alpha.id, bravo.id, charlie.id]) + #expect(added.map(\.descriptor.kind) == [.ssh, .telnet, .ssh]) + #expect(ids.count == 3) + #expect(!ids.contains(existing)) + #expect(app.orderedWorkspaceItems.map(\.id) == [existing] + ids) + #expect(app.savedWorkspaceDescriptors.map(\.id) == [existing] + ids) + #expect(app.connectionHistory.entries(for: alpha.id).count == 2) + #expect(app.connectionHistory.entries(for: bravo.id).map(\.kind) == [.telnet]) + #expect(added.allSatisfy { $0.busy || $0.authenticationRequired }) + } + + @Test func confirmationRechecksProtocolAvailabilityAndAllocatesDistinctNames() throws { + let app = try MobileAppModel(inMemory: true) + defer { app.closeAll() } + #expect(app.workspaceSettings.setAutomaticRenaming(true)) + let first = Host(name: "Server", host: "first.invalid") + let second = Host(name: "Server", host: "second.invalid") + var changed = Host(name: "Changed", host: "changed.invalid") + for host in [first, second, changed] { app.library.upsert(host) } + changed.sshEnabled = false + changed.telnetEnabled = true + app.library.upsert(changed) + + let ids = app.batchStartup( + hostIDs: [first.id, changed.id, second.id, UUID()], connectionProtocol: .sftp) + #expect(ids.count == 2) + #expect(app.workspaces.map(\.descriptor.title) == ["Server", "Server (1)"]) + #expect(app.workspaces.allSatisfy { $0.descriptor.kind == .sftp }) + #expect(app.batchStartup(hostIDs: [], connectionProtocol: .ssh).isEmpty) + } + + @Test func sceneDefersStartupUntilDismissalAndKeepsOtherScenesUnchanged() throws { + let app = try MobileAppModel(inMemory: true) + defer { app.closeAll() } + let host = Host(name: "Host", host: "host.invalid") + app.library.upsert(host) + let first = MobileSceneModel(app: app) + let second = MobileSceneModel(app: app) + second.open(hostID: host.id, kind: .ssh) + let otherWorkspace = second.workspaceID + + first.openBatchStartup() + #expect(first.isBatchStartupPresented) + #expect(!second.isBatchStartupPresented) + first.prepareBatchStartup(hostIDs: [host.id], connectionProtocol: .sftp) + first.prepareBatchStartup(hostIDs: [host.id], connectionProtocol: .ssh) + #expect(app.workspaces.count == 1) + first.completeBatchStartup() + first.completeBatchStartup() + #expect(app.workspaces.count == 2) + #expect(app.workspaces.last?.descriptor.kind == .sftp) + #expect(!first.isBatchStartupPresented) + #expect(first.selectedDestination == .workspaces) + #expect(first.workspaceID == nil) + #expect(second.workspaceID == otherWorkspace) + + first.openBatchStartup() + first.isBatchStartupPresented = false + first.completeBatchStartup() + #expect(app.workspaces.count == 2) + } + + @Test func openingConnectDiscardsAnUnfinishedBatchRequest() throws { + let app = try MobileAppModel(inMemory: true) + defer { app.closeAll() } + let host = Host(name: "Host", host: "host.invalid") + app.library.upsert(host) + let scene = MobileSceneModel(app: app) + scene.openBatchStartup() + scene.prepareBatchStartup(hostIDs: [host.id], connectionProtocol: .ssh) + scene.openConnectionPicker() + scene.completeBatchStartup() + #expect(scene.isConnectionPickerPresented) + #expect(!scene.isBatchStartupPresented) + #expect(app.workspaces.isEmpty) + } +} diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index 6407cdc7..ee3b3b8b 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -227,20 +227,22 @@ struct MobileConnectionPickerTests { @Test( arguments: [ - ("en", "Connect", "More", "Close All"), - ("zh-Hans", "连接", "更多", "全部关闭"), - ("zh-Hant", "連線", "更多", "全部關閉"), + ("en", "Connect", "More", "Close All", "Batch Startup"), + ("zh-Hans", "连接", "更多", "全部关闭", "批量启动"), + ("zh-Hant", "連線", "更多", "全部關閉", "批次啟動"), ]) func workspaceToolbarExposesLocalizedActions( language: String, expectedConnectName: String, expectedMoreName: String, - expectedCloseAllName: String + expectedCloseAllName: String, expectedBatchName: String ) async throws { var connectActivations = 0 + var batchActivations = 0 try await withNavigation( ToolbarHost( hasWorkspaces: true, closeAll: {}, - connect: { connectActivations += 1 }), + connect: { connectActivations += 1 }, + batchStartup: { batchActivations += 1 }), language: language ) { navigation in let connect = try #require(toolbarButton(named: expectedConnectName, in: navigation)) @@ -257,11 +259,22 @@ struct MobileConnectionPickerTests { #expect(more.isEnabled) menu = try #require(more.menu) } - let actions = try await resolvedMenuActions(menu, in: page.view) + let resolved = try await resolvedMenu(menu, in: page.view) + let actions = menuActions(resolved) let closeAll = try #require(actions.first { $0.title == expectedCloseAllName }) #expect(closeAll.attributes.contains(.destructive)) #expect(!closeAll.attributes.contains(.disabled)) - #expect(actions.count == 1) + #expect(actions.map(\.title) == [expectedBatchName, expectedCloseAllName]) + let batch = try #require(actions.first) + #expect(!batch.attributes.contains(.disabled)) + #expect(batch.image == UIImage(systemName: "wind")) + let button = UIButton(primaryAction: batch) + button.sendActions(for: .touchUpInside) + #expect(batchActivations == 1) + let groups = resolved.children.compactMap { $0 as? UIMenu } + .filter { $0.options.contains(.displayInline) } + .map { menuActions($0).map(\.title) } + #expect(groups.contains([expectedCloseAllName])) } } @@ -280,6 +293,8 @@ struct MobileConnectionPickerTests { let actions = try await resolvedMenuActions(menu, in: page.view) let closeAll = try #require(actions.first { $0.title == "Close All" }) #expect(closeAll.attributes.contains(.disabled)) + let batch = try #require(actions.first { $0.title == "Batch Startup" }) + #expect(!batch.attributes.contains(.disabled)) #expect(actions.contains { $0.title == "Clear History" } == false) } } @@ -343,6 +358,10 @@ struct MobileConnectionPickerTests { } private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] { + menuActions(try await resolvedMenu(menu, in: view)) + } + + private func resolvedMenu(_ menu: UIMenu, in view: UIView) async throws -> UIMenu { let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44)) button.menu = menu button.showsMenuAsPrimaryAction = true @@ -351,14 +370,14 @@ struct MobileConnectionPickerTests { button.performPrimaryAction() try await Task.sleep(for: .milliseconds(500)) let interaction = try #require(button.contextMenuInteraction) - var actions: [UIAction] = [] + var result: UIMenu? interaction.updateVisibleMenu { resolved in - actions = menuActions(resolved) + result = resolved return resolved } interaction.dismissMenu() try await Task.sleep(for: .milliseconds(300)) - return actions + return try #require(result) } private func menuActions(_ menu: UIMenu) -> [UIAction] { @@ -401,6 +420,7 @@ private struct ToolbarHost: View { let hasWorkspaces: Bool let closeAll: () -> Void let connect: () -> Void + var batchStartup: () -> Void = {} var body: some View { Color.clear @@ -409,7 +429,8 @@ private struct ToolbarHost: View { MobileWorkspaceToolbar( hasWorkspaces: hasWorkspaces, closeAll: closeAll, - connect: connect + connect: connect, + batchStartup: batchStartup ) } } diff --git a/TFITests/MobileMultiHostPickerTests.swift b/TFITests/MobileMultiHostPickerTests.swift index 13b00222..35ab97aa 100644 --- a/TFITests/MobileMultiHostPickerTests.swift +++ b/TFITests/MobileMultiHostPickerTests.swift @@ -9,7 +9,7 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileMultiHostPickerTests { - @Test(arguments: [HostUseCase.metrics, .snippetExecution]) + @Test(arguments: [HostUseCase.metrics, .snippetExecution, .preferredTerminal, .sftp]) func filteredSelectAllPreservesOtherSelectionsAndLibraryOrder(useCase: HostUseCase) { let hosts = ["Alpha", "Bravo", "Charlie"].enumerated().map { index, name in Host(name: name, host: "\(name.lowercased()).invalid", sortIndex: Double(index))