diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 3a368e06..99023c04 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -28,8 +28,8 @@ struct MobileWorkspaceToolbar: ToolbarContent { } struct MobileConnectionPicker: View { - @Environment(\.colorScheme) private var colorScheme @State private var model: MobileConnectionPickerModel + let library: HostLibraryModel let cancel: () -> Void init( @@ -37,12 +37,19 @@ struct MobileConnectionPicker: View { connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void, cancel: @escaping () -> Void ) { - _model = State(initialValue: MobileConnectionPickerModel(library: library, connect: connect)) + self.library = library + _model = State(initialValue: MobileConnectionPickerModel(connect: connect)) self.cancel = cancel } var body: some View { - List { + MobileHostPicker( + library: library, + title: "Connect", + emptyDescription: "Add a host to connect to it.", + cancel: cancel, + select: model.select + ) { Section { Picker("Connection Protocol", selection: $model.kind) { Text("SSH").tag(MobileWorkspaceDescriptor.Kind.ssh) @@ -50,11 +57,43 @@ struct MobileConnectionPicker: View { } .pickerStyle(.segmented) } + } + } +} + +struct MobileHostPicker: View { + @Environment(\.colorScheme) private var colorScheme + @State private var model: MobileHostPickerModel + let title: LocalizedStringKey + let emptyDescription: LocalizedStringKey + let cancel: () -> Void + let select: (Host) -> Void + let header: Header + + init( + library: HostLibraryModel, + title: LocalizedStringKey, + emptyDescription: LocalizedStringKey, + cancel: @escaping () -> Void, + select: @escaping (Host) -> Void, + @ViewBuilder header: () -> Header + ) { + _model = State(initialValue: MobileHostPickerModel(library: library)) + self.title = title + self.emptyDescription = emptyDescription + self.cancel = cancel + self.select = select + self.header = header() + } + + var body: some View { + List { + header if model.isLibraryEmpty { ContentUnavailableView( "No Hosts", systemImage: "server.rack", - description: Text("Add a host to connect to it.") + description: Text(emptyDescription) ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -63,10 +102,10 @@ struct MobileConnectionPicker: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else { - Section("Hosts") { + Section { ForEach(model.hosts) { host in Button { - model.select(host) + select(host) } label: { HStack(spacing: 12) { HostSystemIcon(asset: distributionEntry(for: host)?.asset, size: 32) @@ -90,7 +129,7 @@ struct MobileConnectionPicker: View { } } .searchable(text: $model.query, prompt: "Search hosts") - .navigationTitle("Connect") + .navigationTitle(Text(title)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -113,28 +152,48 @@ struct MobileConnectionPicker: View { } } +extension MobileHostPicker where Header == EmptyView { + init( + library: HostLibraryModel, + title: LocalizedStringKey, + emptyDescription: LocalizedStringKey, + cancel: @escaping () -> Void, + select: @escaping (Host) -> Void + ) { + self.init( + library: library, + title: title, + emptyDescription: emptyDescription, + cancel: cancel, + select: select, + header: { EmptyView() }) + } +} + @MainActor @Observable -final class MobileConnectionPickerModel { - var kind = MobileWorkspaceDescriptor.Kind.ssh +final class MobileHostPickerModel { var query = "" private let library: HostLibraryModel - private let connect: (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void - init( - library: HostLibraryModel, - connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void - ) { - self.library = library - self.connect = connect - } + init(library: HostLibraryModel) { self.library = library } var isLibraryEmpty: Bool { library.hosts.isEmpty } var hosts: [Host] { library.searchHosts(query) } func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } +} + +@MainActor +@Observable +final class MobileConnectionPickerModel { + var kind = MobileWorkspaceDescriptor.Kind.ssh + + private let connect: (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void + + init(connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void) { self.connect = connect } func select(_ host: Host) { connect(host.id, kind) } } diff --git a/TFI/Features/Metrics/MobileMetricsView.swift b/TFI/Features/Metrics/MobileMetricsView.swift index bd18b693..67be274a 100644 --- a/TFI/Features/Metrics/MobileMetricsView.swift +++ b/TFI/Features/Metrics/MobileMetricsView.swift @@ -49,16 +49,17 @@ struct MobileMetricsView: View { } .sheet(isPresented: $selecting) { NavigationStack { - List(app.library.hosts) { host in - Button(host.name, systemImage: "server.rack") { + MobileHostPicker( + library: app.library, + title: "Add Server", + emptyDescription: "Add a saved Linux server to start monitoring.", + cancel: { selecting = false }, + select: { host in selecting = false if !app.metrics.add(host.id, pro: app.pro.hasProAccess) { app.pro.presentPaywall(for: .unlimitedMetrics) } - } - } - .navigationTitle("Hosts") - .toolbar { Button("Cancel", systemImage: "xmark") { selecting = false }.labelStyle(.iconOnly) } + }) }.mobileSheetPresentation(compact: true) } .task { app.metrics.start() } diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index cbd3032b..e112a8c4 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -12,34 +12,62 @@ struct MobileConnectionPickerTests { @Test func pickerDefaultsToSSHFiltersHostsAndReturnsTheSelectedProtocol() throws { let app = try MobileAppModel(inMemory: true) var selections: [(Host.ID, MobileWorkspaceDescriptor.Kind)] = [] - let model = MobileConnectionPickerModel(library: app.library) { selections.append(($0, $1)) } + let hostModel = MobileHostPickerModel(library: app.library) + let connectionModel = MobileConnectionPickerModel { selections.append(($0, $1)) } - #expect(model.kind == .ssh) - #expect(model.isLibraryEmpty) + #expect(connectionModel.kind == .ssh) + #expect(hostModel.isLibraryEmpty) let production = Host(name: "Production", host: "production.example.invalid") let staging = Host(name: "Staging", host: "staging.example.invalid") app.library.upsert(production) app.library.upsert(staging) - #expect(!model.isLibraryEmpty) - #expect(Set(model.hosts.map(\.id)) == Set([production.id, staging.id])) + #expect(!hostModel.isLibraryEmpty) + #expect(Set(hostModel.hosts.map(\.id)) == Set([production.id, staging.id])) - model.query = "prod" - #expect(model.hosts.map(\.id) == [production.id]) + hostModel.query = "prod" + #expect(hostModel.hosts.map(\.id) == [production.id]) - model.select(production) + connectionModel.select(production) #expect(selections.count == 1) #expect(selections.first?.0 == production.id) #expect(selections.first?.1 == .ssh) - model.kind = .sftp - model.select(staging) + connectionModel.kind = .sftp + connectionModel.select(staging) #expect(selections.count == 2) #expect(selections.last?.0 == staging.id) #expect(selections.last?.1 == .sftp) } + @Test func sharedHostPickerUsesConfiguredTitleWithoutHostsHeaderOrProtocolControl() async throws { + let app = try MobileAppModel(inMemory: true) + app.library.upsert(Host(name: "Production", host: "production.example.invalid")) + + try await withNavigation( + MobileHostPicker( + library: app.library, + title: "Add Server", + emptyDescription: "Add a saved Linux server to start monitoring.", + cancel: {}, + select: { _ in }), + language: "en" + ) { navigation in + #expect(navigation.topViewController?.navigationItem.title == "Add Server") + #expect(descendant(of: UISegmentedControl.self, in: navigation) == nil) + #expect(!labels(in: navigation.view).contains("Hosts")) + } + + try await withNavigation( + MobileConnectionPicker(library: app.library, connect: { _, _ in }, cancel: {}), + language: "en" + ) { navigation in + #expect(descendant(of: UISegmentedControl.self, in: navigation) != nil) + #expect(!labels(in: navigation.view).contains("Hosts")) + } + } + @Test( arguments: [ ("en", "Connect", "More"), @@ -124,6 +152,24 @@ struct MobileConnectionPickerTests { } return nil } + + private func descendant(of type: T.Type, in viewController: UIViewController) -> T? { + descendant(of: type, in: viewController.view) + } + + private func descendant(of type: T.Type, in view: UIView) -> T? { + if let result = view as? T { return result } + for child in view.subviews { + if let result = descendant(of: type, in: child) { return result } + } + return nil + } + + private func labels(in view: UIView) -> [String] { + var result = (view as? UILabel).flatMap(\.text).map { [$0] } ?? [] + for child in view.subviews { result.append(contentsOf: labels(in: child)) } + return result + } } private struct ToolbarHost: View { diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index c0a11a71..1afd7782 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -102,6 +102,17 @@ struct MobileIconRenderingTests { } .environment(\.locale, locale) } + try await capture("metrics-host-picker-\(suffix)", language: language, scheme: scheme) { + NavigationStack { + MobileHostPicker( + library: connectionApp.library, + title: "Add Server", + emptyDescription: "Add a saved Linux server to start monitoring.", + cancel: {}, + select: { _ in }) + } + .environment(\.locale, locale) + } try await capture("sftp-settings-\(suffix)", language: language, scheme: scheme) { NavigationStack { MobileSFTPSettingsView(transfers: app.transfers) }.environment(\.locale, locale) }