Files
luma/Luma/VirtualMachines/VirtualMachinePanel.swift
Ole André Vadla Ravnås c94525fb8a ui: Match the machines empty state to the notebook
Share the notebook's heading, add a button that boots a machine, and
leave out the panel title while there is nothing under it.
2026-09-29 15:38:58 +02:00

322 lines
9.9 KiB
Swift

import Frida
import LumaCore
import SwiftUI
#if canImport(AppKit)
struct VirtualMachinePanel: View {
let engine: Engine
@State private var miniaturized: Set<UUID> = []
@State private var failure: String?
@State private var isShowingBootSheet = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
if !records.isEmpty {
machineList
} else {
emptyState
}
if let failure {
Text(failure)
.font(.footnote)
.foregroundStyle(.red)
}
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var machineList: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(records) { record in
VirtualMachineControls(
engine: engine,
record: record,
isMiniature: miniature(record),
failure: $failure
)
if record.id != records.last?.id {
Divider()
}
}
}
}
}
private func miniature(_ record: VirtualMachineRecord) -> Binding<Bool> {
Binding(
get: { miniaturized.contains(record.id) },
set: { wanted in
if wanted {
miniaturized.insert(record.id)
} else {
miniaturized.remove(record.id)
}
}
)
}
private var emptyState: some View {
VStack(spacing: 24) {
EmptyStateHeading(
title: "Machines",
systemImage: "desktopcomputer",
subtitle: "Boot a guest and instrument anything running in it."
)
Button {
isShowingBootSheet = true
} label: {
Label("Boot Machine", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.sheet(isPresented: $isShowingBootSheet) {
BootVirtualMachineSheet(engine: engine) { _ in }
}
}
private var header: some View {
HStack {
if !records.isEmpty {
Text("Machines")
.font(.headline)
}
Spacer()
Button {
engine.setSidePanel(nil)
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
}
}
private var records: [VirtualMachineRecord] {
engine.virtualMachines.records
}
}
struct VirtualMachineControls: View {
let engine: Engine
let record: VirtualMachineRecord
@Binding var isMiniature: Bool
@Binding var failure: String?
@State private var isConfirmingForget = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
summary
if !isMiniature, let display = machine?.display {
VirtualMachineDisplayView(display: display)
.frame(maxWidth: .infinity)
.containerRelativeFrame(.vertical) { height, _ in height * 0.85 }
}
}
.confirmationDialog("Forget Machine?", isPresented: $isConfirmingForget, titleVisibility: .visible) {
Button("Forget Machine", role: .destructive) {
Task { await engine.virtualMachines.forget(record) }
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will stop \"\(record.name)\" and delete its snapshot. Its disk image is left alone.")
}
}
private var summary: some View {
HStack(alignment: .center, spacing: 10) {
Button {
isMiniature.toggle()
} label: {
Image(systemName: isMiniature ? "chevron.forward" : "chevron.down")
.font(.caption.weight(.semibold))
.frame(width: 12)
}
.buttonStyle(.plain)
.help(isMiniature ? "Show the screen at full size" : "Collapse the screen into a miniature")
HStack(spacing: 10) {
if let icon = engine.virtualMachines.template(for: record)?.icon {
icon.swiftUIImage
.resizable()
.interpolation(.high)
.frame(width: 28, height: 28)
}
VStack(alignment: .leading, spacing: 2) {
Text(record.name)
.font(.subheadline.weight(.medium))
if let template = engine.virtualMachines.template(for: record),
case let detail = template.detailLabel(for: record.parameters),
detail != record.name
{
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.lineLimit(1)
.truncationMode(.tail)
}
.contentShape(Rectangle())
.onTapGesture { isMiniature.toggle() }
Spacer(minLength: 8)
if isMiniature, let display = machine?.display {
VirtualMachineDisplayView(display: display)
.frame(height: Self.miniatureHeight)
.allowsHitTesting(false)
.contentShape(Rectangle())
.onTapGesture { isMiniature = false }
}
if let passingState {
Text(passingState)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
primaryAction
Menu {
lessTravelledActions
} label: {
Image(systemName: "ellipsis.circle")
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 24)
}
}
@ViewBuilder
private var primaryAction: some View {
if let machine {
action("Stop", systemImage: "stop.fill") {
Task { await engine.virtualMachines.stop(record) }
}
.disabled(machine.state == .starting || engine.virtualMachines.isStopping(record))
} else {
action(record.hasReadySnapshot ? "Resume where it was marked ready" : "Boot", systemImage: "play.fill") {
perform { _ = try await engine.virtualMachines.boot(record) }
}
}
}
@ViewBuilder
private var lessTravelledActions: some View {
if let display = machine?.display {
Button("Full Screen") {
VirtualMachineFullScreen.show(display, named: record.name)
}
Divider()
}
if let machine {
Button("Mark Ready") {
perform { try await engine.virtualMachines.markReady(machine) }
}
.disabled(!machine.capabilities.contains(.snapshot))
Button("Back to Ready") {
perform { try await machine.restoreReadySnapshot() }
}
.disabled(!machine.capabilities.contains(.snapshot) || !record.hasReadySnapshot)
} else if record.hasReadySnapshot {
Button("Boot Fresh, Leaving the Snapshot Behind") {
perform { _ = try await engine.virtualMachines.boot(record, resumingFromReadySnapshot: false) }
}
}
if record.hasReadySnapshot {
Button("Forget the Ready Snapshot") {
perform { try await engine.virtualMachines.discardReadySnapshot(record) }
}
}
Divider()
Button("Forget Machine…", role: .destructive) {
isConfirmingForget = true
}
}
private func action(_ name: String, systemImage: String, perform: @escaping () -> Void) -> some View {
Button(action: perform) {
Image(systemName: systemImage)
.frame(width: 18, height: 18)
}
.buttonStyle(.borderless)
.help(name)
.accessibilityLabel(name)
}
private static let miniatureHeight: CGFloat = 40
/// Running and stopped are what the button beside this already says; the
/// states worth a word are the ones on the way to somewhere.
private var passingState: String? {
if engine.virtualMachines.isStopping(record) {
return "Stopping…"
}
switch machine?.state {
case .running, .stopped, nil:
return nil
case .some(let state):
return state.summary
}
}
private var machine: (any VirtualMachine)? {
engine.virtualMachines.machine(for: record)
}
private func perform(_ work: @escaping () async throws -> Void) {
failure = nil
Task {
do {
try await work()
} catch {
failure = error.localizedDescription
}
}
}
}
extension VirtualMachineState {
var summary: String {
switch self {
case .starting: return "Starting…"
case .installing(let fraction): return "Installing… \(Int(fraction * 100))%"
case .running: return "Running"
case .capturingSnapshot: return "Taking a snapshot…"
case .restoringSnapshot: return "Going back to the snapshot…"
case .stopped: return "Stopped"
case .failed(let reason): return reason
}
}
}
#endif