Initial Commit
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user