Files
Termind/TFI/Features/Vault/MobileVaultView.swift
T

909 lines
35 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
enum MobileHistoryPresentation {
static let limit = 50
static func limited<Entry>(_ entries: [Entry]) -> [Entry] {
Array(entries.prefix(limit))
}
}
enum MobileHostMenuAction: Hashable, Sendable {
case ssh
case telnet
case sftp
case edit
case duplicate
case delete
}
nonisolated enum MobileHostMenuPolicy {
static let sections: [[MobileHostMenuAction]] = [
[.ssh, .telnet, .sftp],
[.edit, .duplicate],
[.delete],
]
static func sections(for host: Host) -> [[MobileHostMenuAction]] {
sections.compactMap { section in
let available = section.filter { action in
switch action {
case .ssh, .sftp: host.sshEnabled
case .telnet: host.telnetEnabled
case .edit, .duplicate, .delete: true
}
}
return available.isEmpty ? nil : available
}
}
}
struct MobileConnectionHistoryView: View {
let app: MobileAppModel
let open: (Host.ID, ConnectionHistoryEntry.Kind) -> Void
@Environment(\.locale) private var locale
@State private var query: String
@State private var clearingHistory = false
init(
app: MobileAppModel, initialQuery: String = "",
open: @escaping (Host.ID, ConnectionHistoryEntry.Kind) -> Void
) {
self.app = app
self.open = open
_query = State(initialValue: initialQuery)
}
private var rows: [MobileConnectionHistoryRow] {
_ = app.connectionHistory.revision
return MobileConnectionHistory.eventRows(
history: app.connectionHistory, devices: app.devices, library: app.library,
query: query, limit: MobileHistoryPresentation.limit)
}
var body: some View {
List {
if rows.isEmpty {
Group {
if query.isEmpty {
ContentUnavailableView("No History", systemImage: "clock.arrow.circlepath")
} else {
ContentUnavailableView.search(text: query)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
ForEach(rows) { row in
historyButton(row)
.contextMenu {
let kinds = MobileConnectionHistoryActionPolicy.availableMenuKinds(for: row.host)
ForEach(kinds, id: \.self) { kind in
connectionButton(kind, host: row.host)
}
if !kinds.isEmpty { Divider() }
Button("Delete", systemImage: "trash", role: .destructive) {
app.connectionHistory.delete(row.entry.id)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", systemImage: "trash", role: .destructive) {
app.connectionHistory.delete(row.entry.id)
}
ForEach(MobileConnectionHistoryActionPolicy.swipeKinds, id: \.self) { kind in
connectionButton(kind, host: row.host)
.tint(swipeTint(for: kind))
}
}
}
}
}
.searchable(text: $query.singleLineText, prompt: "Search History")
.navigationTitle("History")
.toolbar {
MobileToolbarOverflow {
Button("Clear All", systemImage: "trash", role: .destructive) {
clearingHistory = true
}
.disabled(app.connectionHistory.entries().isEmpty)
}
}
.confirmationDialog(
"Clear connection history?", isPresented: $clearingHistory,
titleVisibility: .visible
) {
Button("Clear History", role: .destructive) {
app.connectionHistory.clearAll()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This permanently removes all connection history without deleting any Hosts.")
}
}
@ViewBuilder
private func historyButton(_ row: MobileConnectionHistoryRow) -> some View {
if row.canOpen {
Button {
open(row.host.id, row.entry.kind)
} label: {
historyLabel(row)
}
.buttonStyle(.plain)
} else {
historyLabel(row)
.opacity(0.55)
}
}
private func historyLabel(_ row: MobileConnectionHistoryRow) -> some View {
HStack(spacing: 12) {
Image(systemName: row.entry.kind.symbol)
.foregroundStyle(.tint)
.frame(width: 36, height: 36)
.accessibilityLabel(Text(row.entry.kind.label))
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 12) {
Text(row.host.name).foregroundStyle(.primary).lineLimit(1)
Spacer(minLength: 12)
Text(row.sourceDeviceName ?? "—")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
HStack(alignment: .center, spacing: 12) {
Text(row.loginAddress)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 12)
Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
private func connectionButton(
_ kind: ConnectionHistoryEntry.Kind, host: Host
) -> some View {
Button(kind.label, systemImage: kind.symbol) {
guard MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) else { return }
open(host.id, kind)
}
.disabled(!MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host))
}
private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color {
switch kind {
case .ssh: .blue
case .telnet: .purple
case .sftp: .orange
}
}
}
private struct MobileVaultLinkGroup<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 26, *) {
surface(
shape: ConcentricRectangle(corners: .concentric(minimum: 20), isUniform: true),
containerRadius: 20)
} else {
surface(
shape: RoundedRectangle(cornerRadius: 10, style: .continuous),
containerRadius: 10)
}
}
private func surface<S: Shape>(shape: S, containerRadius: CGFloat) -> some View {
VStack(spacing: 0) { content }
.background(Color(uiColor: .secondarySystemGroupedBackground), in: shape)
.clipShape(shape)
.containerShape(
RoundedRectangle(cornerRadius: containerRadius, style: .continuous)
)
}
}
private struct MobileVaultLinkLabel: View {
let title: Text
let detail: Text?
let symbol: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: symbol)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.tint)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
title.foregroundStyle(.primary).lineLimit(1)
if let detail {
detail.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.frame(minHeight: 52)
.contentShape(.rect)
}
}
private struct MobileVaultLinkDivider: View {
var body: some View {
Divider().padding(.leading, 52)
}
}
struct MobileVaultView: View {
private struct GroupEditorRequest: Identifiable {
let id = UUID()
let groupID: HostGroup.ID?
let parentID: HostGroup.ID?
}
private enum SearchDestination {
case keychain(useQuery: Bool)
case portForwarding(useQuery: Bool)
case snippets(useQuery: Bool)
case knownHosts(useQuery: Bool)
case history(useQuery: Bool)
case metrics
}
private struct SearchLink: Identifiable {
let id: String
let title: String
let detail: String?
let symbol: String
let destination: SearchDestination
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.locale) private var locale
let app: MobileAppModel
let reviewScene: MobileSceneModel?
let activateHost: (Host.ID) -> Void
let open: (UUID, ConnectionHistoryEntry.Kind) -> Void
let executeSnippet: (Snippet, [Host.ID]) -> Void
private let openMetrics: (() -> Void)?
@Binding var editingHost: Host?
@State private var query = ""
@State private var deletingHost: Host?
@State private var groupEditorRequest: GroupEditorRequest?
@State private var deletingGroup: HostGroup?
init(
app: MobileAppModel,
reviewScene: MobileSceneModel? = nil,
editingHost: Binding<Host?>,
activateHost: @escaping (Host.ID) -> Void,
open: @escaping (UUID, ConnectionHistoryEntry.Kind) -> Void,
executeSnippet: @escaping (Snippet, [Host.ID]) -> Void = { _, _ in },
openMetrics: (() -> Void)? = nil
) {
self.app = app
self.reviewScene = reviewScene
_editingHost = editingHost
self.activateHost = activateHost
self.open = open
self.executeSnippet = executeSnippet
self.openMetrics = openMetrics
}
private var matchingHosts: [Host] { app.library.searchHosts(query) }
private var matchingKeys: [SSHKeyRef] { app.credentials.searchKeys(query) }
private var matchingIdentities: [Identity] { app.credentials.searchIdentities(query) }
private var matchingSnippets: [Snippet] { app.snippets.searchSnippets(query) }
private var matchingKnownHosts: [KnownHostEntry] { app.knownHosts.search(query) }
private var matchingMetricServers: [ServerMetric] {
app.metrics.servers.filter { $0.name.localizedCaseInsensitiveContains(query) }
}
private var matchesMetricsPage: Bool { pageTitle("Metrics").localizedCaseInsensitiveContains(query) }
private var matchesKeychainPage: Bool { pageTitle("Keychain").localizedCaseInsensitiveContains(query) }
private var matchesSnippetsPage: Bool { pageTitle("Snippets").localizedCaseInsensitiveContains(query) }
private var matchesKnownHostsPage: Bool { pageTitle("Known Hosts").localizedCaseInsensitiveContains(query) }
private var matchesHistoryPage: Bool { pageTitle("History").localizedCaseInsensitiveContains(query) }
private var hostSections: [HostLibrarySection] { HostLibrarySectionLayout.sections(library: app.library) }
private var keychainSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if matchesKeychainPage {
result.append(
SearchLink(
id: "keychain-page", title: pageTitle("Keychain"), detail: nil,
symbol: "key", destination: .keychain(useQuery: false)))
}
result += matchingKeys.map {
SearchLink(
id: "key-\($0.id)", title: $0.label, detail: $0.fingerprint,
symbol: "key", destination: .keychain(useQuery: true))
}
result += matchingIdentities.map {
SearchLink(
id: "identity-\($0.id)", title: $0.name.isEmpty ? $0.username : $0.name,
detail: $0.username, symbol: "person.crop.square", destination: .keychain(useQuery: true))
}
return result
}
private var forwardingSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if pageTitle("Port Forwarding").localizedCaseInsensitiveContains(query) {
result.append(
SearchLink(
id: "forwarding-page", title: pageTitle("Port Forwarding"), detail: nil,
symbol: "arrow.left.arrow.right", destination: .portForwarding(useQuery: false)))
}
result += app.forwardingLibrary.search(query).map { rule in
SearchLink(
id: "forwarding-\(rule.id)", title: rule.displayName(hostName: app.library.host(rule.hostID)?.name),
detail: rule.summary, symbol: rule.kind.symbol, destination: .portForwarding(useQuery: true))
}
return result
}
private var snippetSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if matchesSnippetsPage {
result.append(
SearchLink(
id: "snippets-page", title: pageTitle("Snippets"), detail: nil,
symbol: "curlybraces", destination: .snippets(useQuery: false)))
}
result += matchingSnippets.map {
SearchLink(
id: "snippet-\($0.id)", title: $0.title, detail: $0.body,
symbol: "curlybraces", destination: .snippets(useQuery: true))
}
return result
}
private var knownHostSearchLinks: [SearchLink] {
var result: [SearchLink] = []
if matchesKnownHostsPage {
result.append(
SearchLink(
id: "known-hosts-page", title: pageTitle("Known Hosts"), detail: nil,
symbol: "touchid", destination: .knownHosts(useQuery: false)))
}
result += matchingKnownHosts.map {
SearchLink(
id: "known-host-\($0.id)", title: $0.displayAddress, detail: nil,
symbol: "touchid", destination: .knownHosts(useQuery: true))
}
return result
}
private var historySearchLinks: [SearchLink] {
var result: [SearchLink] = []
if matchesHistoryPage {
result.append(
SearchLink(
id: "history-page", title: pageTitle("History"), detail: nil,
symbol: "clock.arrow.circlepath", destination: .history(useQuery: false)))
}
result += MobileConnectionHistory.eventRows(
history: app.connectionHistory, devices: app.devices, library: app.library,
query: query, limit: MobileHistoryPresentation.limit
).map { row in
SearchLink(
id: "history-\(row.entry.id)", title: row.host.name,
detail: row.sourceDeviceName,
symbol: row.entry.kind.symbol, destination: .history(useQuery: true))
}
return result
}
private var metricSearchLinks: [SearchLink] {
guard openMetrics != nil else { return [] }
var result: [SearchLink] = []
if matchesMetricsPage {
result.append(
SearchLink(
id: "metrics-page", title: pageTitle("Metrics"), detail: nil,
symbol: "waveform.path.ecg", destination: .metrics))
}
result += matchingMetricServers.map {
SearchLink(
id: "metric-\($0.id)", title: $0.name, detail: nil,
symbol: "waveform.path.ecg", destination: .metrics)
}
return result
}
private var hasSearchResults: Bool {
!matchingHosts.isEmpty || !keychainSearchLinks.isEmpty || !snippetSearchLinks.isEmpty
|| !forwardingSearchLinks.isEmpty || !knownHostSearchLinks.isEmpty
|| !historySearchLinks.isEmpty || !metricSearchLinks.isEmpty
}
var body: some View {
MobileCardPage {
if query.isEmpty {
vaultLinks
if app.library.hosts.isEmpty && app.library.groups.isEmpty {
ContentUnavailableView(
"No Hosts", systemImage: "server.rack",
description: Text("Add a host to connect with SSH or SFTP.")
)
.frame(maxWidth: .infinity, minHeight: 280)
}
ForEach(hostSections) { section in
VStack(alignment: .leading, spacing: 12) {
hostSectionHeader(section)
if section.isExpanded != false {
MobileCardGrid {
ForEach(section.hosts) { row($0) }
}
}
}
}
} else {
searchResults
}
}
.searchable(text: $query.singleLineText)
.navigationTitle("Vault")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("New Host", systemImage: "server.rack") { editingHost = Host(name: "", host: "") }
Button("New Group", systemImage: "folder.badge.plus") {
groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: nil)
}
} label: {
Label("Add", systemImage: "plus").labelStyle(.iconOnly)
}
}
}
.sheet(item: $editingHost) { MobileHostEditor(app: app, host: $0) }
.sheet(item: $groupEditorRequest) { request in
MobileHostGroupEditor(
app: app,
group: request.groupID.flatMap(app.library.group),
parentID: request.parentID)
}
.confirmationDialog(
"Delete Host?", isPresented: Binding(get: { deletingHost != nil }, set: { if !$0 { deletingHost = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let host = deletingHost { app.deleteHost(host.id) }
deletingHost = nil
}
} message: {
Text("Open connections and references to this host will be removed.")
}
.confirmationDialog(
groupDeletionTitle,
isPresented: Binding(
get: { deletingGroup != nil },
set: { if !$0 { deletingGroup = nil } }),
titleVisibility: .visible
) {
Button("Delete Group and Hosts", role: .destructive) {
deleteGroup(keepingHosts: false)
}
Button("Keep Hosts") { deleteGroup(keepingHosts: true) }
Button("Cancel", role: .cancel) { deletingGroup = nil }
} message: {
Text(groupDeletionMessage)
}
.mobileReviewPage("vault.hosts", scene: reviewScene)
}
@ViewBuilder
private var searchResults: some View {
if !hasSearchResults {
ContentUnavailableView.search(text: query)
}
if !matchingHosts.isEmpty {
MobileCardSection(title: "Hosts", count: matchingHosts.count) {
MobileCardGrid { ForEach(matchingHosts) { row($0) } }
}
}
if !keychainSearchLinks.isEmpty {
MobileCardSection(title: "Keychain") {
MobileVaultLinkGroup {
searchLinkRows(keychainSearchLinks)
}
}
}
if !forwardingSearchLinks.isEmpty {
MobileCardSection(title: "Port Forwarding") {
MobileVaultLinkGroup { searchLinkRows(forwardingSearchLinks) }
}
}
if !snippetSearchLinks.isEmpty {
MobileCardSection(title: "Snippets") {
MobileVaultLinkGroup {
searchLinkRows(snippetSearchLinks)
}
}
}
if !knownHostSearchLinks.isEmpty {
MobileCardSection(title: "Known Hosts") {
MobileVaultLinkGroup {
searchLinkRows(knownHostSearchLinks)
}
}
}
if !historySearchLinks.isEmpty {
MobileCardSection(title: "History") {
MobileVaultLinkGroup {
searchLinkRows(historySearchLinks)
}
}
}
if !metricSearchLinks.isEmpty {
MobileCardSection(title: "Metrics") {
MobileVaultLinkGroup {
searchLinkRows(metricSearchLinks)
}
}
}
}
@ViewBuilder
private var vaultLinks: some View {
MobileVaultLinkGroup {
vaultLink("Keychain", symbol: "key") {
MobileCredentialsView(app: app)
.mobileReviewPage("vault.keychain", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("Port Forwarding", symbol: "arrow.left.arrow.right") {
MobileForwardingView(app: app, reviewScene: reviewScene)
.mobileReviewPage("vault.portForwarding", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("Snippets", symbol: "curlybraces") {
MobileSnippetsView(app: app, execute: executeSnippet)
.mobileReviewPage("vault.snippets", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("Known Hosts", symbol: "touchid") {
MobileKnownHostsView(app: app)
.mobileReviewPage("vault.knownHosts", scene: reviewScene)
}
MobileVaultLinkDivider()
vaultLink("History", symbol: "clock.arrow.circlepath") {
MobileConnectionHistoryView(app: app, open: open)
.mobileReviewPage("vault.history", scene: reviewScene)
}
}
}
private func vaultLink<Destination: View>(
_ title: LocalizedStringKey, symbol: String,
@ViewBuilder destination: () -> Destination
) -> some View {
NavigationLink(destination: destination) {
MobileVaultLinkLabel(title: Text(title), detail: nil, symbol: symbol)
}
.buttonStyle(.plain)
}
@ViewBuilder
private func searchLinkRows(_ links: [SearchLink]) -> some View {
ForEach(Array(links.enumerated()), id: \.element.id) { index, link in
let label = MobileVaultLinkLabel(
title: Text(verbatim: link.title),
detail: link.detail.map { Text(verbatim: $0) }, symbol: link.symbol)
Group {
if case .metrics = link.destination {
Button {
openMetrics?()
} label: {
label
}
} else {
NavigationLink {
searchDestination(link.destination)
} label: {
label
}
}
}
.buttonStyle(.plain)
if index < links.count - 1 {
MobileVaultLinkDivider()
}
}
}
@ViewBuilder
private func searchDestination(_ destination: SearchDestination) -> some View {
switch destination {
case .keychain(let useQuery):
MobileCredentialsView(app: app, initialQuery: useQuery ? query : "")
.mobileReviewPage("vault.keychain", scene: reviewScene)
case .portForwarding(let useQuery):
MobileForwardingView(app: app, initialQuery: useQuery ? query : "", reviewScene: reviewScene)
.mobileReviewPage("vault.portForwarding", scene: reviewScene)
case .snippets(let useQuery):
MobileSnippetsView(
app: app, initialQuery: useQuery ? query : "", execute: executeSnippet
)
.mobileReviewPage("vault.snippets", scene: reviewScene)
case .knownHosts(let useQuery):
MobileKnownHostsView(app: app, initialQuery: useQuery ? query : "")
.mobileReviewPage("vault.knownHosts", scene: reviewScene)
case .history(let useQuery):
MobileConnectionHistoryView(
app: app, initialQuery: useQuery ? query : "", open: open
)
.mobileReviewPage("vault.history", scene: reviewScene)
case .metrics:
EmptyView()
}
}
private func hostSectionHeader(_ section: HostLibrarySection) -> some View {
HStack(spacing: 8) {
Button {
toggle(section)
} label: {
HStack(spacing: 8) {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.rotationEffect(.degrees(section.isExpanded == true ? 90 : 0))
Text(section.title).font(.headline)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
Text(section.hosts.count, format: .number)
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
Spacer()
if case .group(let id) = section.id, let group = app.library.group(id) {
Menu {
Button("New Host", systemImage: "server.rack") {
editingHost = Host(name: "", host: "", groupID: group.id)
}
Button("New Subgroup", systemImage: "folder.badge.plus") {
groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: group.id)
}
Button("Edit", systemImage: "pencil") {
groupEditorRequest = GroupEditorRequest(groupID: group.id, parentID: group.parentID)
}
Divider()
Button("Delete Group", systemImage: "trash", role: .destructive) {
requestDelete(group)
}
} label: {
Image(systemName: "ellipsis")
.frame(width: 44, height: 44)
}
.accessibilityLabel("More")
} else if case .ungrouped = section.id {
Menu {
Button("New Host", systemImage: "server.rack") {
editingHost = Host(name: "", host: "")
}
Button("New Group", systemImage: "folder.badge.plus") {
groupEditorRequest = GroupEditorRequest(groupID: nil, parentID: nil)
}
} label: {
Image(systemName: "ellipsis")
.frame(width: 44, height: 44)
}
.accessibilityLabel("More")
}
}
.accessibilityElement(children: .contain)
}
private func requestDelete(_ group: HostGroup) {
if DeletionContentCategory.protectedData.requiresConfirmation(for: .item) {
deletingGroup = group
} else {
app.deleteHostGroup(group.id, keepHosts: false)
}
}
private var groupDeletionTitle: String {
guard let group = deletingGroup else { return "" }
return String(
localized: "Delete “\(group.name)”?"
)
}
private var groupDeletionMessage: String {
guard let group = deletingGroup else { return "" }
let hosts = app.library.hosts(inTreeOf: group.id).count
let nested = app.library.descendantGroups(of: group.id).count
let hostCount = String(localized: "\(hosts) hosts")
if nested == 0 {
return String(localized: "This group contains \(hostCount).")
}
let groupCount =
nested == 1
? String(localized: "1 nested group")
: String(localized: "\(nested) nested groups")
return String(localized: "This group contains \(hostCount) and \(groupCount). Nested groups will be deleted.")
}
private func deleteGroup(keepingHosts: Bool) {
guard let group = deletingGroup else { return }
app.deleteHostGroup(group.id, keepHosts: keepingHosts)
deletingGroup = nil
}
private func toggle(_ section: HostLibrarySection) {
guard let isExpanded = section.isExpanded else { return }
switch section.id {
case .group(let id): app.library.setExpanded(!isExpanded, for: id)
case .ungrouped: app.library.setUngroupedExpanded(!isExpanded)
case .results: break
}
}
private func pageTitle(_ value: String.LocalizationValue) -> String {
String(localized: value)
}
private func row(_ host: Host) -> some View {
let entry = LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)
let brand = entry.flatMap {
LinuxDistributionBrandColor.color(hex: $0.hex, colorScheme: colorScheme)
}
let tint = host.colorTag.color ?? brand ?? .accentColor
return MobileActionCard {
activateHost(host.id)
} content: {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
HostSystemIcon(asset: entry?.asset, size: 36)
.tint(tint)
VStack(alignment: .leading, spacing: 4) {
Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1)
Text(app.library.preferredTerminalLoginAddress(for: host)).font(.caption.monospaced())
.foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(spacing: 6) {
ForEach(MobileCardLayout.visibleTags(host.tags), id: \.self) { tag in
MobileCardChip(text: tag)
}
Spacer(minLength: 6)
Text(
app.lastConnectedAt(for: host.id)
.map { $0.formatted(.relative(presentation: .numeric).locale(locale)) }
?? String(localized: "Never")
)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
.accessibilityElement(children: .combine)
.accessibilityHint("Open Workspace")
} actions: {
let sections = MobileHostMenuPolicy.sections(for: host)
ForEach(sections.indices, id: \.self) { section in
ForEach(sections[section], id: \.self) { action in
hostMenuButton(action, host: host)
}
if section < sections.count - 1 {
Divider()
}
}
}
}
@ViewBuilder
private func hostMenuButton(_ action: MobileHostMenuAction, host: Host) -> some View {
switch action {
case .ssh:
Button("SSH", systemImage: ConnectionHistoryEntry.Kind.ssh.symbol) { open(host.id, .ssh) }
case .telnet:
Button("Telnet", systemImage: ConnectionHistoryEntry.Kind.telnet.symbol) { open(host.id, .telnet) }
case .sftp:
Button("SFTP", systemImage: ConnectionHistoryEntry.Kind.sftp.symbol) { open(host.id, .sftp) }
case .edit:
Button("Edit", systemImage: "pencil") { editingHost = host }
case .duplicate:
Button("Duplicate", systemImage: "plus.square.on.square") { app.duplicateHost(host) }
case .delete:
Button("Delete", systemImage: "trash", role: .destructive) { deletingHost = host }
}
}
}
struct MobileHostGroupEditor: View {
let app: MobileAppModel
let original: HostGroup
let existing: Bool
let didSave: (HostGroup) -> Void
@State private var draft: HostGroup
@Environment(\.dismiss) private var dismiss
init(
app: MobileAppModel, group: HostGroup?, parentID: HostGroup.ID?,
didSave: @escaping (HostGroup) -> Void = { _ in }
) {
self.app = app
let initial = group ?? HostGroup(name: "", parentID: parentID)
original = initial
existing = group != nil
self.didSave = didSave
_draft = State(initialValue: initial)
}
private var trimmedName: String {
draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
var body: some View {
MobileEditorSheet(
title: existing ? String(localized: "Edit Group") : String(localized: "New Group"),
isDirty: draft != original, canSave: !trimmedName.isEmpty, compact: true,
save: save
) {
TextField("Name", text: $draft.name.singleLineText, axis: .horizontal)
.singleLinePaste()
.lineLimit(1)
Picker("Inside", selection: $draft.parentID) {
Text("Top Level").tag(HostGroup.ID?.none)
ForEach(parentChoices, id: \.group.id) { choice in
Text(choice.path).tag(HostGroup.ID?.some(choice.group.id))
}
}
}
.onChange(of: app.library.groups.map(\.id)) {
if existing && !app.library.groups.contains(where: { $0.id == original.id }) {
dismiss()
}
}
}
private var parentChoices: [(group: HostGroup, path: String)] {
let forbidden = Set([draft.id] + app.library.descendantGroups(of: draft.id).map(\.id))
return app.library.groups.filter { !forbidden.contains($0.id) }
.map { ($0, path(to: $0)) }
.sorted { $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending }
}
private func path(to group: HostGroup) -> String {
var names = [group.name]
var parentID = group.parentID
var visited: Set<HostGroup.ID> = [group.id]
while let id = parentID, let parent = app.library.group(id), visited.insert(id).inserted {
names.append(parent.name)
parentID = parent.parentID
}
return names.reversed().joined(separator: " / ")
}
private func save() async throws {
draft.name = trimmedName
app.library.upsert(draft)
if let error = app.library.storageError { throw MobileEditorError.message(error) }
didSave(draft)
}
}