Reuse mobile host picker for metrics

This commit is contained in:
Kaede Akino committed 2026-09-12 10:36:21 +08:00
1 parent 4c68a01c48
commit 14bde784ac
4 files changed
+150 -33

No files matched your search

@@ -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<Header: View>: 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) }
}
+7 -6
View File
@@ -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() }
+56 -10
View File
@@ -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<T: UIView>(of type: T.Type, in viewController: UIViewController) -> T? {
descendant(of: type, in: viewController.view)
}
private func descendant<T: UIView>(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 {
+11
View File
@@ -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)
}