416 lines
12 KiB
Swift
416 lines
12 KiB
Swift
|
|
import AppKit
|
|
import SwiftUI
|
|
|
|
struct OrionMenuView: View {
|
|
@EnvironmentObject var vm: OrionVMController
|
|
@Environment(\.openSettings) private var openSettings
|
|
|
|
@State private var expandedGroups: Set<String> = []
|
|
@State private var expandedServers: Set<UUID> = []
|
|
|
|
private var groups: [String] {
|
|
let preferred = [
|
|
"Windows", "Ubuntu", "Debian", "Fedora",
|
|
"CentOS", "Alpine", "Oracle Linux",
|
|
"Rocky Linux", "AlmaLinux", "Arch Linux",
|
|
"Gentoo", "FreeBSD", "Other"
|
|
]
|
|
|
|
return preferred.filter { os in
|
|
vm.discoveredVMs.contains {
|
|
$0.operatingSystem == os
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
|
|
Divider()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
discoverySection
|
|
|
|
Divider()
|
|
.padding(.vertical, 10)
|
|
|
|
integratedSection
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
.frame(maxHeight: 560)
|
|
|
|
Divider()
|
|
|
|
footer
|
|
}
|
|
.frame(width: 410)
|
|
.task {
|
|
await vm.discoverVMs()
|
|
}
|
|
.alert(
|
|
"OrionToolbar",
|
|
isPresented: Binding(
|
|
get: { vm.lastError != nil },
|
|
set: { if !$0 { vm.lastError = nil } }
|
|
)
|
|
) {
|
|
Button("OK") { vm.lastError = nil }
|
|
} message: {
|
|
Text(vm.lastError ?? "")
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Image(systemName: "server.rack")
|
|
.foregroundStyle(.blue)
|
|
|
|
Text("OrionToolbar")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
if vm.discovering {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
Button {
|
|
Task { await vm.discoverVMs() }
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.disabled(vm.discovering)
|
|
.help("Refresh Parallels virtual machines")
|
|
}
|
|
.padding(16)
|
|
}
|
|
|
|
private var discoverySection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("PARALLELS VIRTUAL MACHINES")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Text("\(vm.discoveredVMs.count)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if vm.discoveredVMs.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Virtual Machines",
|
|
systemImage: "desktopcomputer",
|
|
description: Text(
|
|
"Refresh to discover Parallels virtual machines."
|
|
)
|
|
)
|
|
.frame(height: 120)
|
|
}
|
|
|
|
ForEach(groups, id: \.self) { group in
|
|
operatingSystemGroup(group)
|
|
}
|
|
|
|
if !vm.discoveredVMs.isEmpty {
|
|
Divider()
|
|
.padding(.vertical, 6)
|
|
|
|
HStack {
|
|
Text("\(vm.selectedVMIDs.count) selected")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Button("Select All") {
|
|
vm.selectAllVMs()
|
|
}
|
|
|
|
Button("Clear") {
|
|
vm.clearVMSelection()
|
|
}
|
|
.disabled(vm.selectedVMIDs.isEmpty)
|
|
}
|
|
|
|
Button {
|
|
vm.integrateSelectedVMs()
|
|
} label: {
|
|
Label(
|
|
"Integrate Selected",
|
|
systemImage: "plus.circle.fill"
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(vm.selectedVMIDs.isEmpty)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func operatingSystemGroup(
|
|
_ group: String
|
|
) -> some View {
|
|
let machines = vm.discoveredVMs.filter {
|
|
$0.operatingSystem == group
|
|
}
|
|
|
|
return VStack(alignment: .leading, spacing: 3) {
|
|
Button {
|
|
if expandedGroups.contains(group) {
|
|
expandedGroups.remove(group)
|
|
} else {
|
|
expandedGroups.insert(group)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName:
|
|
expandedGroups.contains(group)
|
|
? "chevron.down"
|
|
: "chevron.right"
|
|
)
|
|
.font(.caption)
|
|
.frame(width: 12)
|
|
|
|
Image(systemName: machines.first?.symbol
|
|
?? "desktopcomputer"
|
|
)
|
|
.frame(width: 22)
|
|
|
|
Text(group)
|
|
.fontWeight(.medium)
|
|
|
|
Spacer()
|
|
|
|
Text("\(machines.count)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(.vertical, 9)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if expandedGroups.contains(group) {
|
|
ForEach(machines) { machine in
|
|
machineRow(machine)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func machineRow(
|
|
_ machine: ParallelsVM
|
|
) -> some View {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
Toggle(
|
|
"",
|
|
isOn: Binding(
|
|
get: {
|
|
vm.selectedVMIDs.contains(machine.id)
|
|
},
|
|
set: { selected in
|
|
if selected {
|
|
vm.selectedVMIDs.insert(machine.id)
|
|
} else {
|
|
vm.selectedVMIDs.remove(machine.id)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
.labelsHidden()
|
|
.toggleStyle(.checkbox)
|
|
|
|
if let icon = vm.icon(for: machine) {
|
|
Image(nsImage: icon)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 30, height: 30)
|
|
} else {
|
|
Image(systemName: machine.symbol)
|
|
.font(.title3)
|
|
.frame(width: 30, height: 30)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(machine.name)
|
|
.font(.system(size: 13, weight: .medium))
|
|
|
|
HStack(spacing: 5) {
|
|
Circle()
|
|
.fill(
|
|
machine.status.lowercased() == "running"
|
|
? Color.green : Color.gray
|
|
)
|
|
.frame(width: 7, height: 7)
|
|
|
|
Text(machine.status.capitalized)
|
|
|
|
if vm.isIntegrated(machine) {
|
|
Text("· Integrated")
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.leading, 22)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
private var integratedSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("INTEGRATED SERVERS")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
|
|
ForEach(vm.servers) { server in
|
|
integratedServerRow(server)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func integratedServerRow(
|
|
_ server: ServerDefinition
|
|
) -> some View {
|
|
let status = vm.status(for: server)
|
|
let expanded = expandedServers.contains(server.id)
|
|
|
|
return VStack(alignment: .leading, spacing: 5) {
|
|
Button {
|
|
if expanded {
|
|
expandedServers.remove(server.id)
|
|
} else {
|
|
expandedServers.insert(server.id)
|
|
}
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName:
|
|
expanded ? "chevron.down" : "chevron.right"
|
|
)
|
|
.font(.caption)
|
|
|
|
Circle()
|
|
.fill(status == .running
|
|
? Color.green : Color.gray)
|
|
.frame(width: 9, height: 9)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(server.name)
|
|
.fontWeight(.medium)
|
|
|
|
Text(status.title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(.vertical, 8)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if expanded {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
if status == .stopped {
|
|
action("Start", "play.fill") {
|
|
vm.start(server)
|
|
}
|
|
} else {
|
|
action("Stop", "stop.fill") {
|
|
vm.stop(server)
|
|
}
|
|
.disabled(vm.isBusy(server))
|
|
|
|
action("Restart", "arrow.clockwise") {
|
|
vm.restart(server)
|
|
}
|
|
.disabled(
|
|
vm.isBusy(server) ||
|
|
status != .running
|
|
)
|
|
|
|
action(
|
|
"Force Stop",
|
|
"exclamationmark.triangle"
|
|
) {
|
|
vm.forceStop(server)
|
|
}
|
|
.disabled(vm.isBusy(server))
|
|
}
|
|
|
|
action("Open SSH in Terminal", "terminal") {
|
|
vm.openSSH(server)
|
|
}
|
|
.disabled(
|
|
status != .running ||
|
|
server.host.isEmpty
|
|
)
|
|
|
|
action("Open Mailpit", "envelope") {
|
|
vm.openMailpit(server)
|
|
}
|
|
.disabled(
|
|
status != .running ||
|
|
server.mailpitPort == nil ||
|
|
server.host.isEmpty
|
|
)
|
|
}
|
|
.padding(.leading, 26)
|
|
.padding(.bottom, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func action(
|
|
_ title: String,
|
|
_ symbol: String,
|
|
perform: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: perform) {
|
|
Label(title, systemImage: symbol)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 5)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var footer: some View {
|
|
VStack(spacing: 2) {
|
|
action("Open Parallels Desktop", "macwindow") {
|
|
vm.openParallels()
|
|
}
|
|
|
|
action("Settings…", "gearshape") {
|
|
openSettings()
|
|
NSApplication.shared.activate(
|
|
ignoringOtherApps: true
|
|
)
|
|
}
|
|
|
|
Divider()
|
|
|
|
action("Quit OrionToolbar", "xmark") {
|
|
NSApplication.shared.terminate(nil)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|