Files
Termind/TFM/Features/HostChooser/HostChooserView.swift
T

913 lines
32 KiB
Swift

import Darwin
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
nonisolated struct SnippetHostChooserRequest: Hashable, Sendable {
enum Strategy: Hashable, Sendable {
case fillMissingTargets
case overrideAllTargets
}
var snippetIDs: [Snippet.ID]
var strategy: Strategy
}
nonisolated enum HostConnectionProtocol: String, CaseIterable, Identifiable, Sendable {
case terminal = "SSH"
case sftp = "SFTP"
case serial = "Serial"
nonisolated var id: Self { self }
func displayName(locale: Locale = .current) -> String {
switch self {
case .terminal: AppLocalization.string("SSH", locale: locale)
case .sftp: AppLocalization.string("SFTP", locale: locale)
case .serial: AppLocalization.string("Serial", locale: locale)
}
}
var target: HostOpenTarget? {
switch self {
case .terminal: .preferredTerminal
case .sftp: .sftpChannel
case .serial: nil
}
}
}
nonisolated enum HostChooserPurpose: Hashable, Sendable, Identifiable {
case connect
case batchStartup
case metrics
case snippet(SnippetHostChooserRequest)
nonisolated var id: Self { self }
var title: LocalizedStringKey {
switch self {
case .connect: "Connect"
case .batchStartup: "Batch Startup"
case .metrics: "Add Server"
case .snippet: "Run"
}
}
var symbol: String {
switch self {
case .connect: "network"
case .batchStartup: MenuSymbol.batchStartup
case .metrics: "waveform.path.ecg"
case .snippet: "text.badge.play"
}
}
/// Named for what it does, not "OK". Multi-selection and Serial use this
/// explicit action, while clicking a host performs the ordinary Connect case.
var confirmLabel: LocalizedStringKey {
switch self {
case .connect, .batchStartup: "Connect"
case .metrics: "Add Server"
case .snippet: "Run"
}
}
/// Whether the sheet ticks boxes instead of picking one row. The only thing
/// the view branches on.
var allowsMultiple: Bool {
switch self {
case .connect: false
case .batchStartup, .snippet, .metrics: true
}
}
/// A single host is the complete answer, matching the mobile picker. Batch
/// purposes keep collecting rows until their explicit confirmation action.
var activatesHostImmediately: Bool {
switch self {
case .connect: true
case .batchStartup, .snippet, .metrics: false
}
}
var choosesConnectionProtocol: Bool {
switch self {
case .connect, .batchStartup: true
case .snippet, .metrics: false
}
}
var availableConnectionProtocols: [HostConnectionProtocol] {
switch self {
case .connect: HostConnectionProtocol.allCases
case .batchStartup: [.terminal, .sftp]
case .metrics, .snippet: []
}
}
func connectionChooserShowsConfirmation(for connectionProtocol: HostConnectionProtocol) -> Bool {
switch self {
case .connect: connectionProtocol == .serial
case .batchStartup: true
case .metrics, .snippet: false
}
}
}
/// The sheet ⌘T raises: click a host, or highlight one with the keyboard and
/// press Return.
///
/// A sheet rather than a page because it is one question with one answer, and
/// the window behind it is what you were looking at when you asked — navigating
/// away to the Hosts page to run one command threw that context away and moved
/// the sidebar highlight as a side effect.
///
/// The sheet opens with nothing preselected, on purpose. A command that inherits
/// a selection made earlier on another screen is how a keystroke meant for
/// staging lands on production; the same rule is written on
/// `AppModel.newSession`. Typing is different — it narrows the list to something
/// the user is looking at, so the top match highlights and Return opens it.
///
/// The rows live in a native `List`, but they are buttons rather than
/// `List(selection:)`: the search field keeps focus while the arrow-key cursor
/// remains visibly highlighted. In single mode the row is the action and opens
/// immediately; no second click or footer confirmation is required.
///
/// Batch Startup raises this same sheet with `purpose.allowsMultiple`. There,
/// rows carry trailing checkmarks and `cursor` stops being the answer — it is
/// only the keyboard position, while `selected` is what Connect acts on.
struct HostChooserView: View {
/// How this chooser got on screen, and therefore what closing it means.
///
/// The same view is raised two ways: as a sheet over the window, and as
/// Vault's third column when a Snippet on the Snippets page asks for its
/// targets. Closing used to be inferred from the front destination, which
/// got it wrong for the case that is now easy to reach — the Snippets shelf
/// in the sidebar raises the *sheet* while Vault is the front destination,
/// so Cancel called `closeVaultWork()` and the sheet stayed up with nothing
/// to dismiss it.
enum Presentation {
case sheet
case vaultWork
}
let purpose: HostChooserPurpose
let presentation: Presentation
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.dismiss) private var dismiss
@Environment(\.locale) private var locale
@State private var chooser: ManagedChooserState<Host.ID>
@State private var connectionProtocol = HostConnectionProtocol.terminal
@State private var serialConfiguration = SerialConnectionConfiguration()
@State private var serialBaudRate = "9600"
@State private var serialPorts: [SerialPortDescriptor] = []
@State private var serialError: String?
@State private var isConfirmingDestructiveSnippet = false
init(purpose: HostChooserPurpose, presentation: Presentation = .sheet) {
self.purpose = purpose
self.presentation = presentation
_chooser = State(
initialValue: ManagedChooserState(
mode: purpose.allowsMultiple ? .multiple : .single
)
)
}
/// The protocol-choosing variant carries an extra row of controls above the
/// list and needs the room; everything else uses the standard picker width.
private var sheetWidth: CGFloat? {
guard presentation == .sheet else { return nil }
return purpose.choosesConnectionProtocol ? 520 : Metrics.HostPicker.width
}
private var query: String { chooser.query }
private var queryBinding: Binding<String> {
Binding(get: { chooser.query }, set: { chooser.query = $0 })
}
private var results: [Host] {
let useCase: HostUseCase =
switch purpose {
case .metrics: .metrics
case .snippet: .snippetExecution
case .connect, .batchStartup:
connectionProtocol == .sftp ? .sftp : .preferredTerminal
}
let matches = app.hostLibrary.hosts(matching: query, for: useCase)
guard purpose == .metrics else { return matches }
let monitored = Set(app.serverMetrics.selections.map(\.id))
return matches.filter { !monitored.contains($0.id) }
}
var body: some View {
Group {
switch purpose {
case .connect, .batchStartup:
connectionChooser
case .metrics, .snippet:
managedChooser
}
}
// A sheet has to name its own size. The host list has no intrinsic
// height, so without one the panel collapses to its header and footer
// with no room to show a single host — which is what "choose targets
// for this Snippet" used to open as. Vault's third column supplies its
// own geometry, so the chooser fills it instead.
.frame(
width: sheetWidth,
height: presentation == .sheet ? Metrics.HostPicker.height : nil
)
.animation(Motion.panel, value: connectionProtocol)
.onChange(of: chooser.query) {
let ids = results.map(\.id)
chooser.queryDidChange(visible: ids, enabled: Set(ids))
}
.task(id: connectionProtocol) {
guard connectionProtocol == .serial else { return }
for await ports in app.serialPorts.portUpdates() {
guard !Task.isCancelled else { return }
serialPorts = ports
}
}
.confirmationDialog(
"Run a destructive script?",
isPresented: $isConfirmingDestructiveSnippet,
titleVisibility: .visible
) {
Button(
chooser.selected.count == 1
? "Run on 1 Host" : "Run on \(chooser.selected.count) Hosts",
role: .destructive
) {
performSnippetExecution()
}
Button("Cancel", role: .cancel) {}
} message: {
Text(snippetConfirmationMessage)
}
}
// MARK: - Pieces
private var managedChooser: some View {
SheetScaffold {
managedHeader
} content: {
managedContent
} footer: {
managedFooter
}
}
private var connectionChooser: some View {
VStack(spacing: 0) {
connectionTopBar
if connectionProtocol == .serial {
serialForm
} else {
VStack(spacing: Metrics.Space.m) {
ConnectionChooserSearchField(
prompt: "Search hosts",
query: queryBinding,
onSubmit: openSelected,
moveCursor: move,
toggleCursor: toggleCursor
)
.padding(.horizontal, Metrics.Space.xl)
connectionHostList
}
.padding(.top, Metrics.Space.xs)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
}
private var connectionTopBar: some View {
HStack {
glassActionButton(
"Cancel",
symbol: "xmark",
identifier: "host-chooser.cancel",
action: close
)
.keyboardShortcut(.cancelAction)
Spacer()
if showsTopConfirmation {
glassActionButton(
purpose.confirmLabel,
symbol: "checkmark",
identifier: "host-chooser.confirm",
action: openSelected
)
.keyboardShortcut(.defaultAction)
.disabled(connectIsDisabled)
} else {
Color.clear.frame(width: 30, height: 30)
}
}
.frame(maxWidth: .infinity)
.overlay {
connectionProtocolPicker
.termindGlassSurface(cornerRadius: Metrics.Radius.medium, isInteractive: true)
.frame(width: protocolPickerWidth, height: 30)
}
.padding(.horizontal, Metrics.Space.xl)
.padding(.top, Metrics.Space.l)
.padding(.bottom, Metrics.Space.s)
.accessibilityElement(children: .contain)
}
private var protocolPickerWidth: CGFloat {
purpose.availableConnectionProtocols.count == 3 ? 240 : 180
}
private func glassActionButton(
_ label: LocalizedStringKey,
symbol: String,
identifier: String,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Label(label, systemImage: symbol)
.labelStyle(.iconOnly)
.frame(width: 30, height: 30)
.contentShape(.circle)
.accessibilityIdentifier(identifier)
}
.accessibilityIdentifier(identifier)
.buttonStyle(.plain)
.termindGlassCircle(isInteractive: true)
.help(label)
.accessibilityLabel(Text(label))
}
private var showsTopConfirmation: Bool {
purpose.connectionChooserShowsConfirmation(for: connectionProtocol)
}
private var managedHeader: some View {
ManagedChooserHeader(
title: purpose.title,
symbol: purpose.symbol,
prompt: "Search hosts",
query: queryBinding,
aboveSearch: chooserOptions,
showsSearch: connectionProtocol != .serial,
onSubmit: openSelected,
moveCursor: move,
toggleCursor: toggleCursor
)
}
private var chooserOptions: AnyView? {
if purpose == .metrics, app.serverMetrics.remainingCardSlots != nil {
return AnyView(
Text("Free includes up to 3 monitoring cards. Upgrade to Pro for more.")
.font(.caption).foregroundStyle(.secondary))
}
return nil
}
private var connectionProtocolPicker: some View {
Picker("Connection Protocol", selection: $connectionProtocol) {
ForEach(purpose.availableConnectionProtocols) { connectionProtocol in
Text(connectionProtocol.displayName(locale: locale)).tag(connectionProtocol)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.accessibilityLabel("Connection protocol")
}
@ViewBuilder
private var managedContent: some View {
if app.hostLibrary.hosts.isEmpty {
ContentUnavailableView {
Label("No Hosts", systemImage: "server.rack")
} description: {
Text("Add a host to connect to it.")
} actions: {
Button("New Host") {
close()
window.openHostEditor(host: nil)
}
}
} else if results.isEmpty {
ContentUnavailableView.search(text: query)
} else {
ScrollViewReader { proxy in
List {
ForEach(results) { host in
Button {
activate(host)
} label: {
HostChooserRow(
host: host,
isCursor: chooser.cursor == host.id,
isChecked: purpose.allowsMultiple
? chooser.selected.contains(host.id) : nil,
connectionProtocol: connectionProtocol
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("host-chooser.host.\(host.id)")
.id(host.id)
.listRowBackground(
chooser.cursor == host.id
? Color.accentColor.opacity(0.11) : Color.clear
)
}
}
.listStyle(.inset)
.onAppear { scrollCursorIntoView(using: proxy) }
.onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) }
.onChange(of: results.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) }
}
}
}
private var connectionHostList: some View {
VStack(spacing: 0) {
if purpose.allowsMultiple {
Button(selectAllLabel) {
toggleAllResults()
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.m)
.contentShape(.rect)
.disabled(results.isEmpty)
.accessibilityIdentifier("host-chooser.select-all")
Divider()
}
connectionHosts
}
}
@ViewBuilder
private var connectionHosts: some View {
if app.hostLibrary.hosts.isEmpty {
ContentUnavailableView {
Label("No Hosts", systemImage: "server.rack")
} description: {
Text("Add a host to connect to it.")
} actions: {
Button("New Host") {
close()
window.openHostEditor(host: nil)
}
}
.frame(maxHeight: .infinity)
} else if results.isEmpty {
ContentUnavailableView.search(text: query)
.frame(maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List {
ForEach(results) { host in
hostButton(host)
}
}
.listStyle(.inset)
.onAppear { scrollCursorIntoView(using: proxy) }
.onChange(of: chooser.cursor) { _, _ in scrollCursorIntoView(using: proxy) }
.onChange(of: results.map(\.id)) { _, _ in scrollCursorIntoView(using: proxy) }
}
}
}
private func hostButton(_ host: Host) -> some View {
Button {
activate(host)
} label: {
HostChooserRow(
host: host,
isCursor: chooser.cursor == host.id,
isChecked: purpose.allowsMultiple
? chooser.selected.contains(host.id) : nil,
connectionProtocol: connectionProtocol
)
.accessibilityIdentifier("host-chooser.host.\(host.id)")
}
.accessibilityIdentifier("host-chooser.host.\(host.id)")
.buttonStyle(.plain)
.id(host.id)
.listRowBackground(
chooser.cursor == host.id
? Color.accentColor.opacity(0.11) : Color.clear
)
}
private func scrollCursorIntoView(using proxy: ScrollViewProxy) {
guard let cursor = chooser.cursor else { return }
proxy.scrollTo(cursor)
}
private var serialForm: some View {
Form {
Section("Device") {
SerialEditableComboField(
title: "Device Path",
value: $serialConfiguration.devicePath,
options: serialPorts.map {
SerialComboOption(value: $0.path, label: $0.displayName)
}
)
if serialPorts.isEmpty {
Text("No detected serial devices. You can still enter any absolute device path.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Communication") {
SerialEditableComboField(
title: "Baud Rate",
value: $serialBaudRate,
options: SerialConnectionConfiguration.commonBaudRates.map {
SerialComboOption(value: String($0), label: $0.formatted())
}
)
Picker("Data Bits", selection: $serialConfiguration.dataBits) {
ForEach(SerialDataBits.allCases) { Text("\($0.rawValue)").tag($0) }
}
Picker("Parity", selection: $serialConfiguration.parity) {
ForEach(SerialParity.allCases) { Text($0.displayName).tag($0) }
}
Picker("Stop Bits", selection: $serialConfiguration.stopBits) {
ForEach(SerialStopBits.allCases) { Text("\($0.rawValue)").tag($0) }
}
Picker("Flow Control", selection: $serialConfiguration.flowControl) {
ForEach(SerialFlowControl.allCases) { Text($0.displayName).tag($0) }
}
}
Section("Terminal") {
Picker("Charset", selection: $serialConfiguration.charset) {
ForEach(RemoteCharset.allCases) { charset in
Text(charset.displayName).tag(charset)
}
}
Picker("Enter Sends", selection: $serialConfiguration.enterKey) {
ForEach(SerialEnterKey.allCases) { Text($0.displayName).tag($0) }
}
Toggle("Local Echo", isOn: $serialConfiguration.localEcho)
}
if let serialError {
Text(serialError).foregroundStyle(.red).font(.caption)
}
}
.formStyle(.grouped)
.onChange(of: serialConfiguration.devicePath) {
serialConfiguration.deviceName =
serialPorts.first {
$0.path == serialConfiguration.devicePath
}?.name
serialError = nil
}
.onChange(of: serialBaudRate) {
if let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) {
serialConfiguration.baudRate = rate
serialError = nil
}
}
}
private var managedFooter: some View {
HStack {
if purpose.allowsMultiple, connectionProtocol != .serial {
Text(
AppLocalization.string(
"\(chooser.selected.count) selected", locale: locale
)
)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
Button(selectAllLabel) {
toggleAllResults()
}
.disabled(results.isEmpty)
}
Spacer()
Button("Cancel", action: close)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("host-chooser.cancel")
Button(purpose.confirmLabel, action: openSelected)
.keyboardShortcut(.defaultAction)
.disabled(connectIsDisabled)
.accessibilityIdentifier("host-chooser.confirm")
}
.padding(Metrics.Space.l)
}
private var connectIsDisabled: Bool {
if connectionProtocol == .serial {
guard let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) else {
return true
}
return !serialConfiguration.isValid
}
return purpose.allowsMultiple ? chooser.selected.isEmpty : selectedHost == nil
}
// MARK: - Checking
private var allResultsChecked: Bool { chooser.allChecked(in: results.map(\.id)) }
private func toggleAllResults() {
chooser.toggleAll(in: results.map(\.id))
}
// MARK: - Opening
private var selectedHost: Host? {
chooser.cursor.flatMap { id in results.first { $0.id == id } }
}
private func openSelected() {
if connectionProtocol == .serial {
openSerial()
} else if purpose.allowsMultiple {
openChecked()
} else if let host = selectedHost {
open(host)
}
}
private func openSerial() {
guard serialConfiguration.isValid else { return }
guard let rate = Int(serialBaudRate), (1...4_000_000).contains(rate) else { return }
serialConfiguration.baudRate = rate
let path = serialConfiguration.normalizedDevicePath
var info = stat()
guard lstat(path, &info) == 0 else {
serialError = AppLocalization.string(
"The device path does not exist.", locale: locale
)
return
}
guard (info.st_mode & S_IFMT) == S_IFCHR else {
serialError = AppLocalization.string(
"The device path is not a character device.", locale: locale
)
return
}
serialConfiguration.devicePath = path
window.openSerialSession(configuration: serialConfiguration, service: app.serialPorts)
dismiss()
}
private func openChecked() {
let batch = app.hostLibrary.searchHosts("").filter { chooser.selected.contains($0.id) }
guard !batch.isEmpty else { return }
if case .snippet = purpose {
requestSnippetExecution()
} else if purpose == .metrics {
// The parent admits the complete batch after this sheet has closed,
// so a limit paywall never competes with the chooser presentation.
window.pendingMetricsHostIDs = batch.map(\.id)
dismiss()
} else {
guard let target = connectionProtocol.target else { return }
app.open(batch, as: target, layout: .oneWorkspace, in: window)
dismiss()
}
}
private var snippetRequest: SnippetHostChooserRequest? {
if case .snippet(let request) = purpose { return request }
return nil
}
private var requestedSnippets: [Snippet] {
guard let request = snippetRequest else { return [] }
return request.snippetIDs.compactMap { app.hostLibrary.snippet($0) }
}
private func requestSnippetExecution() {
if requestedSnippets.contains(where: {
CommandRiskClassifier.assess($0.body).risk == .destructive
}) {
isConfirmingDestructiveSnippet = true
} else {
performSnippetExecution()
}
}
private func performSnippetExecution() {
guard let request = snippetRequest else { return }
let ids = app.hostLibrary.searchHosts("").filter { chooser.selected.contains($0.id) }.map(\.id)
switch request.strategy {
case .fillMissingTargets:
app.execute(requestedSnippets, fallbackHostIDs: ids, in: window)
case .overrideAllTargets:
app.execute(requestedSnippets, overrideHostIDs: ids, in: window)
}
close()
}
private var snippetConfirmationMessage: String {
let selectedIDs = app.hostLibrary.searchHosts("")
.filter { chooser.selected.contains($0.id) }.map(\.id)
return requestedSnippets.map { snippet in
let ids: [Host.ID]
switch snippetRequest?.strategy {
case .fillMissingTargets:
ids = snippet.targetHostIDs.isEmpty ? selectedIDs : snippet.targetHostIDs
case .overrideAllTargets, nil:
ids = selectedIDs
}
let wanted = Set(ids)
let targets = app.hostLibrary.searchHosts("").filter { wanted.contains($0.id) }
.map(\.name).joined(separator: ", ")
return "• \(snippet.body)\n \(targets)"
}.joined(separator: "\n")
}
private func close() {
switch presentation {
case .sheet: dismiss()
case .vaultWork: window.closeVaultWork()
}
}
private var selectAllLabel: String {
AppLocalization.string(
allResultsChecked ? "Deselect All" : "Select All", locale: locale
)
}
private func move(_ delta: Int) -> KeyPress.Result {
guard !results.isEmpty else { return .ignored }
chooser.moveCursor(by: delta, through: results.map(\.id))
return .handled
}
private func toggleCursor() -> KeyPress.Result {
guard purpose.allowsMultiple, let id = chooser.cursor else { return .ignored }
chooser.toggle(id)
return .handled
}
private func activate(_ host: Host) {
chooser.activate(host.id)
if purpose.activatesHostImmediately { open(host) }
}
private func open(_ host: Host) {
guard let target = connectionProtocol.target else { return }
app.open(host, as: target, in: window)
dismiss()
}
}
private struct ConnectionChooserSearchField: View {
let prompt: LocalizedStringKey
@Binding var query: String
var onSubmit: () -> Void = {}
var moveCursor: (Int) -> KeyPress.Result = { _ in .ignored }
var toggleCursor: () -> KeyPress.Result = { .ignored }
@FocusState private var searchFocused: Bool
var body: some View {
HStack(spacing: Metrics.Space.s) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(prompt, text: $query.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit(onSubmit)
.onKeyPress(.upArrow) { moveCursor(-1) }
.onKeyPress(.downArrow) { moveCursor(1) }
.onKeyPress(.space, action: toggleCursor)
if !query.isEmpty {
Button {
query = ""
searchFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Clear search"))
}
}
.padding(Metrics.Space.s)
.background(
.quaternary.opacity(0.5),
in: .rect(cornerRadius: Metrics.Radius.medium)
)
.onAppear {
Task { @MainActor in
await Task.yield()
searchFocused = true
}
}
}
}
// MARK: - Row
/// One host, as the chooser lists it. The shared piece between the verbs: what
/// you need to recognise a machine is the same whether you are about to type on
/// it or copy files to it.
///
/// `isChecked` is nil outside batch mode. Multi-selection reveals the native
/// trailing checkmark only for rows that will be acted on.
struct HostChooserRow: View {
let host: Host
let isCursor: Bool
var isChecked: Bool?
var connectionProtocol: HostConnectionProtocol = .terminal
@Environment(AppModel.self) private var app
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Metrics.Space.m) {
HostSystemIcon(asset: distributionEntry?.asset, size: 36)
.tint(iconTint)
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(host.name)
.foregroundStyle(.primary)
.lineLimit(1)
Text(
connectionProtocol == .terminal
? app.hostLibrary.preferredTerminalLoginAddress(for: host)
: app.hostLibrary.loginAddress(for: host)
)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.s)
if isChecked == true {
Image(systemName: "checkmark")
.font(.body.weight(.semibold))
.foregroundStyle(.tint)
.accessibilityHidden(true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
.accessibilityElement(children: .combine)
// In batch mode "selected" has to mean ticked, not where the cursor sits
// — the tick is what Connect acts on, so it is what VoiceOver must read.
.accessibilityAddTraits((isChecked ?? isCursor) ? .isSelected : [])
}
private var distributionEntry: LinuxDistributionIconEntry? {
LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)
}
private var iconTint: Color {
host.colorTag.color
?? distributionEntry.flatMap {
LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme)
}
?? .accentColor
}
}
#Preview("Connect") {
HostChooserView(purpose: .connect)
.environment(AppModel(hosts: .demo()))
.environment(WindowModel())
}
/// Batch Startup uses the same continuous picker, with a confirmation action
/// opposite Cancel and a Select All row above the hosts.
#Preview("Batch Startup") {
HostChooserView(purpose: .batchStartup)
.environment(AppModel(hosts: .demo()))
.environment(WindowModel())
}