feat(ios): add batch startup to workspaces

This commit is contained in:
Kaede Akino committed 2026-09-25 19:50:37 +08:00
1 parent f3b124aee0
commit a728b48df2
13 files changed
+495 -32

No files matched your search

+13
View File
@@ -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
+29
View File
@@ -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) })
}
}
+144
View File
@@ -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)
}
}
+32 -11
View File
@@ -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
)
}
}
+1 -1
View File
@@ -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))