feat(ios): add batch startup to workspaces
This commit is contained in:
1 parent
f3b124aee0
commit
a728b48df2
13 files changed
+495
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ final class MobileMultiHostPickerModel {
|
||||
private(set) var selected: Set<Host.ID>
|
||||
|
||||
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) }
|
||||
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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<Accessory: View>: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import TermindPresentation
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
struct MobileMultiHostPicker: View {
|
||||
struct MobileMultiHostPicker<Header: View>: 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<Host.ID>,
|
||||
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() })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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: View>(
|
||||
_ 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<T: UIViewController>(_ 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<T: UIView>(_ 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<Host.ID>
|
||||
@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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user