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

257 lines
8.2 KiB
Swift

import SwiftUI
import TermindDomain
import struct TermindDomain.Host
#if canImport(AppKit)
import AppKit
#endif
/// Everything you can do to a host, in one menu.
///
/// Shared by Vault host surfaces, the Welcome page, and the workspace list's
/// recent-hosts menu — places that would otherwise drift into slightly
/// different sets of verbs.
struct HostContextMenu: View {
let hosts: [Host]
let showsDuplicate: Bool
let requestDelete: (() -> Void)?
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
init(
host: Host,
showsDuplicate: Bool = true,
requestDelete: (() -> Void)? = nil
) {
hosts = [host]
self.showsDuplicate = showsDuplicate
self.requestDelete = requestDelete
}
init(
hosts: [Host],
showsDuplicate: Bool = true,
requestDelete: @escaping () -> Void
) {
self.hosts = hosts
self.showsDuplicate = showsDuplicate
self.requestDelete = requestDelete
}
var body: some View {
// No "Connect in Split": a Workspace is built by combining workspaces that are
// already open, so connecting never decides a layout for you. Connect,
// then drag the new workspace onto the one you want it beside.
Button("Quick Connect", systemImage: MenuSymbol.quickConnect) {
app.open(
hosts,
as: .preferredTerminal,
layout: HostMenuPolicy.quickConnectLayout(for: hosts),
in: window
)
}
HostConnectMenu(hosts: hosts)
if hosts.count == 1, let host = hosts.first {
copySSHCommandButton(host)
}
Divider()
if hosts.count == 1, let host = hosts.first {
Button("Edit", systemImage: MenuSymbol.edit) { window.openHostEditor(host: host) }
if showsDuplicate {
Button("Duplicate", systemImage: MenuSymbol.duplicate) {
var copy = host
if host.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
copy.identityID = nil
}
if host.proxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
copy.proxy?.identityID = nil
copy.proxy?.username = ""
}
if host.telnetIdentityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
copy.telnetIdentityID = nil
}
if host.telnetProxy?.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
copy.telnetProxy?.identityID = nil
copy.telnetProxy?.username = ""
}
copy.id = UUID()
copy.name = String(localized: "\(host.name) copy")
app.hostLibrary.upsert(copy)
}
}
Divider()
}
if let requestDelete {
DestructiveMenuButton("Delete") {
requestDelete()
}
.disabled(hosts.isEmpty)
}
}
@ViewBuilder
private func copySSHCommandButton(_ host: Host) -> some View {
Button("Copy SSH Command", systemImage: MenuSymbol.copy) {
guard let command = app.hostLibrary.sshCommand(for: host) else { return }
copyToPasteboard(command)
}
.disabled(app.hostLibrary.sshCommand(for: host) == nil)
.help(
app.hostLibrary.sshCommand(for: host) == nil
? "SSH commands are unavailable for routes that use a Proxy."
: "Copy an OpenSSH command for this route."
)
}
}
enum HostMenuPolicy {
static func quickConnectLayout(for hosts: [Host]) -> BatchLayout {
hosts.count > 1 ? .oneWorkspace : .separateWorkspaces
}
}
enum HostConnectAction: CaseIterable, Hashable {
case ssh
case telnet
case sftp
static func available(for hosts: [Host]) -> [Self] {
allCases.filter { action in hosts.contains(where: action.supports) }
}
func matchingHosts(in hosts: [Host]) -> [Host] {
hosts.filter(supports)
}
var target: HostOpenTarget {
switch self {
case .ssh: .sshSession
case .telnet: .telnetSession
case .sftp: .sftpChannel
}
}
var title: LocalizedStringResource {
switch self {
case .ssh: "with SSH"
case .telnet: "with Telnet"
case .sftp: "with SFTP"
}
}
var symbol: String {
switch self {
case .ssh: MenuSymbol.ssh
case .telnet: MenuSymbol.telnet
case .sftp: MenuSymbol.sftp
}
}
private func supports(_ host: Host) -> Bool {
switch self {
case .ssh, .sftp: host.sshEnabled
case .telnet: host.telnetEnabled
}
}
}
/// `HostContextMenu` as native menu entries, for the sidebar rows that draw
/// their own selection outline and so cannot use SwiftUI's `contextMenu`.
///
/// Deliberately in this file, beside the SwiftUI version: these two are one set
/// of verbs and the only thing keeping them from drifting is being read
/// together. Anything added above belongs here too.
@MainActor
enum HostMenuEntries {
static func entries(
for host: Host,
app: AppModel,
window: WindowModel,
includesCopySSHCommand: Bool = true
) -> [NativeMenuEntry] {
let connectActions = HostConnectAction.available(for: [host])
var entries = [
NativeMenuEntry(
title: String(localized: "Quick Connect"), symbol: MenuSymbol.quickConnect
) {
app.open([host], as: .preferredTerminal, in: window)
}
]
if !connectActions.isEmpty {
entries.append(
NativeMenuEntry(
title: String(localized: "Connect"),
symbol: MenuSymbol.connect,
children: connectActions.map { action in
NativeMenuEntry(
title: String(localized: action.title), symbol: action.symbol
) {
app.open(action.matchingHosts(in: [host]), as: action.target, in: window)
}
}
))
}
if includesCopySSHCommand {
entries.append(
NativeMenuEntry(
title: String(localized: "Copy SSH Command"),
symbol: MenuSymbol.copy,
isEnabled: app.hostLibrary.sshCommand(for: host) != nil
) {
guard let command = app.hostLibrary.sshCommand(for: host) else { return }
copyToPasteboard(command)
})
}
entries.append(.separator)
entries.append(
NativeMenuEntry(title: String(localized: "Edit"), symbol: MenuSymbol.edit) {
window.openHostEditor(host: host)
})
return entries
}
}
struct HostConnectMenu: View {
let hosts: [Host]
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
var body: some View {
let actions = HostConnectAction.available(for: hosts)
if !actions.isEmpty {
Menu("Connect", systemImage: MenuSymbol.connect) {
ForEach(actions, id: \.self) { action in
Button {
app.open(action.matchingHosts(in: hosts), as: action.target, in: window)
} label: {
Label {
Text(action.title)
} icon: {
Image(systemName: action.symbol)
}
}
}
}
}
}
}
/// One place that knows about `NSPasteboard`, so the views stay platform-neutral
/// for the eventual iPadOS layout.
@discardableResult
func copyToPasteboard(_ text: String) -> Bool {
#if canImport(AppKit)
NSPasteboard.general.clearContents()
return NSPasteboard.general.setString(text, forType: .string)
#else
return false
#endif
}