Files
Termind/TFM/Features/Hosts/HostsPageView.swift
T

600 lines
21 KiB
Swift

import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
@Observable
final class HostsPageState {
enum ConfirmationRoute: Identifiable {
case deleteGroup(HostGroup)
case deleteHosts([Host])
var id: String {
switch self {
case .deleteGroup(let group): "delete-group-\(group.id)"
case .deleteHosts(let hosts):
"delete-hosts-\(hosts.map(\.id.uuidString).sorted().joined(separator: "-"))"
}
}
}
var query = ""
/// Which tags the grid is narrowed to, ticked in the titlebar search
/// field's magnifier menu. Several at once means "carries all of these".
var selectedTags: Set<String> = []
var confirmation: ConfirmationRoute?
func requestDeletion(of hosts: [Host]) {
guard !hosts.isEmpty,
DeletionContentCategory.protectedData.requiresConfirmation(for: .item)
else { return }
confirmation = .deleteHosts(hosts)
}
/// Drops filters for tags no host carries any more.
///
/// There is no tag registry to delete from — `HostLibraryModel.hostTags` is
/// derived from the hosts themselves — so a tag disappears the moment its
/// last host loses it. Without this the page would sit filtered by a tag
/// that matches nothing, showing an empty grid and no obvious way back.
func pruneSelectedTags(against available: [String]) {
let live = Set(available)
guard !selectedTags.isSubset(of: live) else { return }
selectedTags.formIntersection(live)
}
}
/// Every saved host, as a grid of cards grouped the way the user filed them.
///
/// This page is where the detail that used to crowd the sidebar now lives. The
/// layout follows Rayon: sections of cards rather than a sortable table, because
/// picking a machine is recognition — color, name, status dot — not a sorting
/// problem. Groups are the sections, so the hierarchy the user built in the host
/// editor is the hierarchy they see here.
///
/// Selecting cards updates the Hosts page's ordered Vault selection, which
/// the Details tab and this page's own Connect and Edit buttons read. Double-click
/// connects. Nothing outside this page acts on that selection — ⌘T raises the
/// host chooser rather than reusing whatever is highlighted here.
struct HostsPageView: ManagedCardPage {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(VaultWorkEditingSession.self) private var editingSession
let state: HostsPageState
init(state: HostsPageState = HostsPageState()) {
self.state = state
}
/// A plain click replaces the window-scoped selection; Shift-click and a
/// background marquee extend it. Keeping it on `WindowModel` lets the page
/// restore the same cards when its workspace becomes current again.
private var hostSelection: Set<Host.ID> {
Set(
window.vaultSelection(for: .hosts).compactMap { detail in
detail.hostID
})
}
/// Tags the list is narrowed to. Several at once means "carries all of
/// these" — see `Host.hasTags`.
// MARK: - Sections
private var isFiltering: Bool { !state.query.isEmpty || !state.selectedTags.isEmpty }
/// Filtering collapses the grouping into one "Results" section: when you are
/// hunting for a name, being told which folder it sits in is noise. Tags
/// narrow the same way — and stack with the field, so `prod` plus "web"
/// means both.
private var sections: [HostLibrarySection] {
HostLibrarySectionLayout.sections(
library: app.hostLibrary,
query: state.query,
selectedTags: state.selectedTags
)
}
// MARK: - Page
var managedSections: [ManagedCardSection<HostLibrarySection.ID, Host>] {
sections.map { section in
ManagedCardSection(
id: section.id, title: section.title, items: section.hosts,
isExpanded: section.isExpanded,
canAcceptDrop: section.id != .results
)
}
}
var managedSelection: ManagedCardSelection<Host.ID> {
ManagedCardSelection(selected: { hostSelection }, update: setHostSelection)
}
var managedCapabilities: ManagedCardCapabilities<HostLibrarySection.ID, Host.ID> {
ManagedCardCapabilities(
selection: .multiple,
marqueeSelection: true,
reordering: ManagedCardReordering(
isEnabled: !isFiltering,
dropPolicy: .anySection
) { ids, destination, next in
app.hostLibrary.move(hostIDs: ids, into: destination.groupID, before: next)
}
)
}
var isManagedSourceEmpty: Bool {
app.hostLibrary.hosts.isEmpty && app.hostLibrary.groups.isEmpty
}
@ViewBuilder
func managedHeader(
for section: ManagedCardSection<HostLibrarySection.ID, Host>, isDropTarget: Bool
) -> some View {
if case .group(let groupID) = section.id, let group = app.hostLibrary.group(groupID) {
let isExpanded = app.hostLibrary.isHostGroupExpanded(group.id)
let toggle = { app.hostLibrary.setExpanded(!isExpanded, for: group.id) }
CardSectionHeader(
title: section.title,
count: section.items.count,
toggle: toggle,
isCollapsed: !isExpanded,
isDropTarget: isDropTarget
)
.contextMenu {
Button("New Host", systemImage: MenuSymbol.newHost) {
openHostEditor(nil, groupID: group.id)
}
Button("New Subgroup", systemImage: MenuSymbol.newGroup) {
openWork(.hostGroupEditor(groupID: nil, parentID: group.id))
}
Button("Edit", systemImage: MenuSymbol.edit) { editGroup(group) }
Divider()
DestructiveMenuButton("Delete Group") { requestDelete(group) }
}
} else if case .ungrouped = section.id {
let isExpanded = section.isExpanded ?? true
let toggle = { app.hostLibrary.setUngroupedExpanded(!isExpanded) }
CardSectionHeader(
title: section.title,
count: section.items.count,
toggle: toggle,
isCollapsed: !isExpanded,
isDropTarget: isDropTarget
)
.contextMenu {
Button("New Host", systemImage: MenuSymbol.newHost) {
openHostEditor(nil)
}
Button("New Group", systemImage: MenuSymbol.newGroup) {
openWork(.hostGroupEditor(groupID: nil, parentID: nil))
}
}
} else {
CardSectionHeader(
title: section.title,
count: section.items.count,
isDropTarget: isDropTarget
)
}
}
@ViewBuilder
func managedCard(
for host: Host, context: ManagedCardItemContext<Host>
) -> some View {
HostCard(
host: host,
isSelected: context.isSelected,
select: { select(host, action: context.select) },
extendSelection: context.extendSelection,
actionHosts: context.actionItems,
requestDelete: { state.requestDeletion(of: context.actionItems) }
)
}
func managedGhost(for host: Host) -> some View {
HostGhostCard(host: host, address: app.hostLibrary.preferredTerminalLoginAddress(for: host))
}
/// The canvas beside the cards, which is where New Host, New Group and the
/// tag filter live now that the dashed tiles and the page toolbar are gone.
/// Right-clicking inside a group files the new host there without a second
/// decision.
func managedBlankAreaMenu(for section: HostLibrarySection.ID?) -> [NativeMenuEntry] {
let groupID = section?.groupID
return [
NativeMenuEntry(title: String(localized: "New Host"), symbol: MenuSymbol.newHost) {
openHostEditor(nil, groupID: groupID)
},
NativeMenuEntry(title: String(localized: "New Group"), symbol: MenuSymbol.newGroup) {
openWork(.hostGroupEditor(groupID: nil, parentID: groupID))
},
.separator,
tagFilterMenu,
.separator,
NativeMenuEntry(title: String(localized: "Import .ssh Folder"), symbol: MenuSymbol.importItem) {
openWork(.sshImport(knownHostsOnly: false))
},
]
}
func managedBlankAreaPrimaryAction() {
guard let route = window.vaultWorkRoute, case .hostEditor = route else { return }
editingSession.requestTransition(route: route, action: window.closeVaultWork)
}
/// Every tag in use, each one a toggle, plus a way out.
///
/// There is no Delete Tag, and no tag registry behind this either:
/// `HostLibraryModel.hostTags` is derived from the hosts themselves, so a
/// tag stops being offered the moment its last host stops carrying it.
private var tagFilterMenu: NativeMenuEntry {
let tags = app.hostLibrary.hostTags
var children = tags.map { tag in
NativeMenuEntry(title: tag, isOn: state.selectedTags.contains(tag)) {
if state.selectedTags.contains(tag) {
state.selectedTags.remove(tag)
} else {
state.selectedTags.insert(tag)
}
}
}
if !tags.isEmpty {
children.append(.separator)
children.append(
NativeMenuEntry(
title: String(localized: "Clear Filter"),
symbol: MenuSymbol.clearFilter,
isEnabled: !state.selectedTags.isEmpty
) { state.selectedTags.removeAll() }
)
}
// The tags themselves stay bare: they are data rather than verbs, and
// `isOn` already claims the column beside them.
return NativeMenuEntry(
title: String(localized: "Filter by Tag"),
symbol: MenuSymbol.filterByTag,
isEnabled: !tags.isEmpty,
children: children
)
}
// MARK: - Deleting a group
private func requestDelete(_ group: HostGroup) {
if DeletionContentCategory.protectedData.requiresConfirmation(for: .item) {
state.confirmation = .deleteGroup(group)
} else {
delete(group, keepHosts: false)
}
}
private func delete(_ group: HostGroup, keepHosts: Bool) {
app.deleteHostGroup(group.id, keepHosts: keepHosts)
state.confirmation = nil
pruneHostSelection()
window.pruneVaultSelections(in: app.hostLibrary)
}
private func delete(_ hosts: [Host]) {
for host in hosts { app.requestHostDeletion(host.id) }
state.confirmation = nil
pruneHostSelection()
window.pruneVaultSelections(in: app.hostLibrary)
}
private func deletionMessage(for group: HostGroup) -> String {
let hosts = app.hostLibrary.hosts(inTreeOf: group.id).count
let nested = app.hostLibrary.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.")
}
// MARK: - Host selection
private var visibleHosts: [Host] {
sections.flatMap(\.hosts)
}
private func setHostSelection(_ ids: Set<Host.ID>) {
let valid = ids.intersection(Set(app.hostLibrary.hosts.map(\.id)))
let ordered = visibleHosts.filter { valid.contains($0.id) }.map { VaultSelectionID.host($0.id) }
window.setVaultSelection(ordered, for: .hosts)
}
private func pruneHostSelection() {
setHostSelection(hostSelection)
}
// MARK: - Chrome
func managedEmptyState() -> some View {
ContentUnavailableView {
Label("No Hosts", systemImage: "server.rack")
} description: {
Text("Save a host here, or enter ssh user@host on the Welcome page to connect once.")
} actions: {
Button("New Host") { openHostEditor(nil) }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
func managedNoResultsState() -> some View {
if state.query.isEmpty {
ContentUnavailableView {
Label("No Matching Hosts", systemImage: "tag")
} description: {
Text("No hosts have all selected tags.")
} actions: {
Button("Clear Filter") { state.selectedTags.removeAll() }
}
} else {
ContentUnavailableView.search(text: state.query)
}
}
var managedPresentation: HostsPagePresentation {
HostsPagePresentation(
state: state,
liveTags: app.hostLibrary.hostTags,
actions: HostsPagePresentation.Actions(
deleteGroup: { delete($0, keepHosts: $1) },
deleteHosts: delete,
deletionMessage: deletionMessage
)
)
}
private func editGroup(_ group: HostGroup) {
openWork(.hostGroupEditor(groupID: group.id, parentID: group.parentID))
}
private func select(_ host: Host, action: @escaping () -> Void) {
guard let route = window.vaultWorkRoute, case .hostEditor(let editingID, _) = route else {
action()
return
}
guard editingID != host.id else {
action()
return
}
editingSession.requestTransition(route: route) {
action()
openHostEditor(host)
}
}
private func openHostEditor(_ host: Host?, groupID: HostGroup.ID? = nil) {
window.openHostEditor(host: host, groupID: groupID)
}
private func openWork(_ route: VaultWorkRoute) {
window.openVaultWork(route, on: .hosts)
}
}
struct HostsPagePresentation: ManagedCardPresentationModifier {
struct Actions {
let deleteGroup: (HostGroup, Bool) -> Void
let deleteHosts: ([Host]) -> Void
let deletionMessage: (HostGroup) -> String
}
let state: HostsPageState
/// Tags at least one host still carries — see `HostsPageState.pruneSelectedTags`.
let liveTags: [String]
let actions: Actions
func body(content: Content) -> some View {
@Bindable var state = state
content
.onChange(of: liveTags, initial: true) { _, tags in
state.pruneSelectedTags(against: tags)
}
.confirmationDialog(
confirmationTitle,
isPresented: Binding(
get: { state.confirmation != nil },
set: { if !$0 { state.confirmation = nil } }
)
) {
switch state.confirmation {
case .deleteGroup(let group):
Button("Delete Group and Hosts", role: .destructive) {
actions.deleteGroup(group, false)
}
Button("Keep Hosts") { actions.deleteGroup(group, true) }
case .deleteHosts(let hosts):
Button("Delete", role: .destructive) {
actions.deleteHosts(hosts)
}
case nil:
EmptyView()
}
Button("Cancel", role: .cancel) { state.confirmation = nil }
} message: {
confirmationMessage
}
}
private var confirmationTitle: Text {
switch state.confirmation {
case .deleteGroup(let group): Text("Delete “\(group.name)”?")
case .deleteHosts(let hosts):
hosts.count == 1 ? Text("Delete Host?") : Text("Delete Hosts?")
case nil: Text("")
}
}
private var confirmationMessage: Text {
switch state.confirmation {
case .deleteGroup(let group): Text(actions.deletionMessage(group))
case .deleteHosts:
Text("Open connections and references to the selected hosts will be removed.")
case nil: Text("")
}
}
}
// MARK: - Card
private struct HostCard: View {
@Environment(\.locale) private var locale
let host: Host
let isSelected: Bool
let select: () -> Void
let extendSelection: () -> Void
let actionHosts: [Host]
let requestDelete: () -> Void
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
private var status: ConnectionStatus { app.hostLibrary.status(of: host.id) }
var body: some View { card }
private var card: some View {
CardTile(
selectionID: host.id,
isSelected: isSelected,
select: select,
extendSelection: extendSelection,
activate: quickConnect,
activatesOnReturn: true
) {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
header
footer
}
}
.contextMenu {
HostContextMenu(
hosts: actionHosts,
requestDelete: requestDelete
)
}
.help(Text("\(app.hostLibrary.preferredTerminalLoginAddress(for: host)) — \(status.label)"))
.accessibilityLabel(host.name)
.accessibilityHint("Quick Connect")
.accessibilityAction(named: Text("Quick Connect"), quickConnect)
}
private func quickConnect() {
app.open(actionHosts, as: .preferredTerminal, in: window)
}
private var header: some View {
HStack(spacing: Metrics.Space.m) {
HostDistributionIcon(host: host)
VStack(alignment: .leading, spacing: 1) {
Text(host.name)
.font(.headline)
.lineLimit(1)
Text(app.hostLibrary.preferredTerminalLoginAddress(for: host))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.xs)
StatusDot(status: status)
}
}
private var footer: some View {
HStack(spacing: Metrics.Space.xs) {
ForEach(host.tags.prefix(2), id: \.self) { tag in
CardChip(text: tag)
}
Spacer(minLength: Metrics.Space.xs)
Text(
app.lastConnectedAt(for: host.id)
.map { $0.formatted(.relative(presentation: .numeric).locale(locale)) }
?? String(localized: "Never")
)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
/// The exact card-shaped slot the dragged batch will occupy. It lives in the
/// adaptive grid, so neighbouring cards reflow around the preview instead of an
/// overlay merely pointing at a position that has not opened yet.
private struct HostGhostCard: View {
let host: Host
let address: String
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
HStack(spacing: Metrics.Space.m) {
HostDistributionIcon(host: host)
VStack(alignment: .leading, spacing: 1) {
Text(host.name)
.font(.headline)
.lineLimit(1)
Text(address)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: Metrics.Space.xs)
}
Spacer(minLength: 0)
}
.foregroundStyle(Color.accentColor)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Metrics.Space.l)
.frame(minHeight: Metrics.Card.minHeight, alignment: .top)
.background(Color.accentColor.opacity(0.12), in: .rect(cornerRadius: Metrics.Radius.large))
.overlay {
RoundedRectangle(cornerRadius: Metrics.Radius.large)
.strokeBorder(
Color.accentColor,
style: StrokeStyle(lineWidth: 1, dash: [4, 3])
)
}
.allowsHitTesting(false)
.transition(.opacity)
}
}
private struct HostDistributionIcon: View {
let host: Host
@Environment(\.colorScheme) private var colorScheme
var body: 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 ?? TermindColor.accent
if let entry {
CardIcon(asset: entry.asset, tint: tint)
} else {
CardIcon(symbol: "server.rack", tint: tint)
}
}
}