snapshots: add snapshot management to the VM details view

Snapshots could only be managed through scripting. Split the details view
into tabs (a tab view on macOS and a segmented control elsewhere, following
the HIG) and add a Snapshots tab next to the existing details.

The tab lists the current state followed by every snapshot, newest first,
along a timeline. A snapshot never changes unless it is overwritten, and
restoring one replaces the current state with a copy, so the actions say as
much: the current state can be saved as a new snapshot or overwrite the one
it is based on, and a snapshot can be restored or deleted. Anything that
loses data asks first.

A snapshot is based on whatever the current state was based on when it was
created. One that follows what it is based on is joined to it so a lineage
without branches reads as a single line. A base that is elsewhere in the
list is named by a link instead, which keeps branches of any depth from
widening the list. The lineage of the focused row is highlighted and only
that row shows its actions. Rows choose their layout from the measured
width because a regular size class can still be narrow next to the sidebar.

The operations only modify an in-memory list for now and no snapshots are
read from the VM yet.

Whether a snapshot holds the running machine decides whether restoring it
resumes the guest or starts it up, so the mark on the timeline is filled for one
that does and hollow for one that holds only the disks.

Assisted-by: Claude:claude-fable-5-1
This commit is contained in:
osy
2026-09-22 10:57:29 -05:00
parent a69ad794dd
commit cb9e95af4d
4 changed files with 1230 additions and 44 deletions
+119 -44
View File
@@ -30,6 +30,18 @@ struct VMDetailsView: View {
private let regularScreenSizeClass: Bool = true
#endif
private enum Tab: Hashable {
case details
case snapshots
}
/// Scroll targets, the screenshot is scrolled away to make room for the snapshots
private enum ScrollAnchor: Hashable {
case screenshot
case tabs
}
@State private var selectedTab: Tab = .details
@State private var size: Int64 = 0
@State private var updateTask: Task<Void, Never>?
private let updateIpIntervalNs = UInt64(10 * 1_000_000_000)
@@ -51,53 +63,24 @@ struct VMDetailsView: View {
Spacer()
}
} else {
ScrollView {
Screenshot(vm: vm, large: regularScreenSizeClass)
let notes = vm.detailsNotes ?? ""
if regularScreenSizeClass && !notes.isEmpty {
HStack(alignment: .top) {
Details(vm: vm, sizeLabel: sizeLabel)
.frame(maxWidth: .infinity)
Text(notes)
.font(.body)
.frame(maxWidth: .infinity, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.padding([.leading, .trailing])
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
ScrollViewReader { scrollProxy in
ScrollView {
Screenshot(vm: vm, large: regularScreenSizeClass)
.id(ScrollAnchor.screenshot)
#if WITH_REMOTE // FIXME: implement remote feature
detailsPane
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
.padding([.leading, .trailing, .bottom])
tabs(scrollProxy: scrollProxy)
#endif
} else {
VStack {
Details(vm: vm, sizeLabel: sizeLabel)
if !notes.isEmpty {
Text(notes)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
#endif
}.padding([.leading, .trailing, .bottom])
}
}.labelStyle(DetailsLabelStyle())
#if !WITH_REMOTE
.onChange(of: selectedTab) { tab in
withAnimation {
scrollProxy.scrollTo(tab == .snapshots ? ScrollAnchor.tabs : ScrollAnchor.screenshot, anchor: .top)
}
}
#endif
}
.modifier(VMOptionalNavigationTitleModifier(vm: vm))
.modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass))
.sheet(isPresented: $data.showSettingsModal) {
@@ -128,6 +111,98 @@ struct VMDetailsView: View {
}
}
#if !WITH_REMOTE
/// Panes below the screenshot that is shared by all of them.
@ViewBuilder private func tabs(scrollProxy: ScrollViewProxy) -> some View {
let snapshotsPane = VMSnapshotsView(vm: vm) { id in
withAnimation {
scrollProxy.scrollTo(id)
}
}
#if os(macOS)
TabView(selection: $selectedTab) {
detailsPane
.padding(.top)
.tabItem { Text("Details") }
.tag(Tab.details)
snapshotsPane
.padding()
.tabItem { Text("Snapshots") }
.tag(Tab.snapshots)
}.padding([.leading, .trailing, .bottom])
.padding(.top, 8)
.id(ScrollAnchor.tabs)
#else
Picker("View", selection: $selectedTab) {
Text("Details").tag(Tab.details)
Text("Snapshots").tag(Tab.snapshots)
}.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 400)
.padding([.leading, .trailing, .top])
.padding(.bottom, 8)
.id(ScrollAnchor.tabs)
switch selectedTab {
case .details:
detailsPane
case .snapshots:
snapshotsPane
.padding([.leading, .trailing, .bottom])
}
#endif
}
#endif
/// A single view, because a tab view makes a separate tab of every view it is given
@ViewBuilder private var detailsPane: some View {
VStack {
let notes = vm.detailsNotes ?? ""
if regularScreenSizeClass && !notes.isEmpty {
HStack(alignment: .top) {
Details(vm: vm, sizeLabel: sizeLabel)
.frame(maxWidth: .infinity)
Text(notes)
.font(.body)
.frame(maxWidth: .infinity, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.padding([.leading, .trailing])
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
.padding([.leading, .trailing, .bottom])
#endif
} else {
VStack {
Details(vm: vm, sizeLabel: sizeLabel)
if !notes.isEmpty {
Text(notes)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuConfig = vm.config as! UTMQemuConfiguration
VMRemovableDrivesView(vm: vm, config: qemuConfig)
#endif
}.padding([.leading, .trailing, .bottom])
}
}.labelStyle(DetailsLabelStyle())
}
#if !WITH_REMOTE
private func clearGuestIPs() {
guard let qemuVM = vm.wrapped as? UTMQemuVirtualMachine else {
+886
View File
@@ -0,0 +1,886 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// Timeline of the current state followed by every saved snapshot, newest first.
///
/// Designed to be placed inside a parent scroll view. `scrollTo` is called with the
/// identifier of a row that should be made visible.
struct VMSnapshotsView: View {
@ObservedObject var vm: VMData
var scrollTo: (UUID) -> Void = { _ in }
var body: some View {
SnapshotTimeline(vm: vm, list: VMSnapshotList.list(for: vm), scrollTo: scrollTo)
.id(vm.id)
}
}
// MARK: - Timeline
private struct SnapshotTimeline: View {
@ObservedObject var vm: VMData
@ObservedObject var list: VMSnapshotList
let scrollTo: (UUID) -> Void
@EnvironmentObject private var data: UTMData
/// Focused snapshot, `nil` focuses the current state
@State private var selectedID: UUID?
@State private var renamingID: UUID?
@State private var confirmation: Confirmation?
/// Keyboard commands go to the selected snapshot while this is set
@FocusState private var isTimelineFocused: Bool
/// Asked before anything that cannot be taken back
private enum Confirmation: Equatable {
case delete(UUID)
/// The current state is replaced by the snapshot
case restore(UUID)
/// The snapshot the current state is based on is replaced by the current state
case overwriteBase
case deleteSuspendState
/// Snapshot it is about, `nil` if it is about the current state
var id: UUID? {
switch self {
case .delete(let id), .restore(let id): return id
case .overwriteBase, .deleteSuspendState: return nil
}
}
}
/// Snapshot the current state started out from, if it still exists
private var baseSnapshot: VMSnapshot? {
list.snapshot(for: list.currentParentID)
}
/// The suspended state can be deleted whether the VM is running or not, just not in between
private var isReady: Bool {
vm.state == .stopped || vm.state == .started || vm.state == .paused
}
/// Measured because a regular size class can still be narrow, such as next to the sidebar
@State private var width: CGFloat?
#if os(macOS)
private let minimumRegularWidth: CGFloat = 520
private let isCompactSizeClass: Bool = false
#else
private let minimumRegularWidth: CGFloat = 660
@Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass?
private var isCompactSizeClass: Bool {
horizontalSizeClass == .compact
}
#endif
/// Row actions move below the text when there is no room to keep them on the trailing edge
private var isCompact: Bool {
if let width = width {
return width < minimumRegularWidth
} else {
return isCompactSizeClass
}
}
/// How a row ties into the rail drawn along the leading edge.
private struct Link {
/// Parent is the next row so the rail continues into it
var connectsDown: Bool = false
/// Previous row is a child so the rail arrives from it
var connectsUp: Bool = false
/// Parent that is somewhere else in the list
var distantParent: VMSnapshot?
var isInLineage: Bool = false
var isUpInLineage: Bool = false
}
/// Links for the current state (first) followed by each snapshot.
private var links: [Link] {
let snapshots = list.snapshots
let lineage = list.lineage(from: selectedID ?? list.currentParentID)
let parents = [list.currentParentID] + snapshots.map { $0.parentID }
let ids = [nil] + snapshots.map { Optional($0.id) }
var links = [Link](repeating: Link(), count: ids.count)
for index in links.indices {
if let id = ids[index] {
links[index].isInLineage = lineage.contains(id)
} else {
links[index].isInLineage = selectedID == nil
}
guard let parentID = parents[index] else {
continue
}
if index + 1 < ids.count && ids[index + 1] == parentID {
links[index].connectsDown = true
links[index + 1].connectsUp = true
links[index + 1].isUpInLineage = links[index].isInLineage
} else {
links[index].distantParent = list.snapshot(for: parentID)
}
}
return links
}
var body: some View {
let links = links
VStack(alignment: .leading, spacing: 0) {
SnapshotRow(isCompact: isCompact,
isFocused: selectedID == nil,
isSelected: false,
rail: rail(for: links[0], hasState: nil),
distantParent: links[0].distantParent,
onSelect: { select(nil) },
onSelectParent: selectParent) {
SnapshotThumbnail(image: vm.screenshotImage, isCompact: isCompact)
} title: {
Text("Current State")
.font(.headline)
} details: {
Text(currentStatus)
} actions: {
currentActions
}
.confirmationDialog("Confirm", isPresented: isConfirming(nil), presenting: confirmation) { confirmation in
Button("Cancel", role: .cancel) {}
switch confirmation {
case .overwriteBase:
Button("Overwrite", role: .destructive) {
overwriteBase()
}
case .deleteSuspendState:
Button("Delete", role: .destructive) {
deleteSuspendState()
}
case .delete(_), .restore(_):
EmptyView()
}
} message: { confirmation in
switch confirmation {
case .overwriteBase:
Text("Do you want to overwrite the snapshot “\(baseSnapshot?.title ?? "")” with the current state of the virtual machine? What it holds now will be lost.")
case .deleteSuspendState:
Text("Do you want to delete the suspended state? The virtual machine will start up normally and all unsaved data will be lost.")
case .delete(_), .restore(_):
EmptyView()
}
}
if list.snapshots.isEmpty {
emptyPlaceholder
} else {
// the rail is drawn by the rows so it is not interrupted by the separator
Divider()
.padding(.leading, SnapshotRail.width)
.padding(.vertical, 6)
.background(alignment: .leading) {
if links[0].connectsDown {
SnapshotRail.Segment(style: links[0].isInLineage ? .lineage : .normal)
.frame(width: SnapshotRail.width)
}
}
}
ForEach(Array(zip(list.snapshots, links.dropFirst())), id: \.0.id) { snapshot, link in
SnapshotRow(isCompact: isCompact,
isFocused: selectedID == snapshot.id,
isSelected: selectedID == snapshot.id,
rail: rail(for: link, hasState: snapshot.hasState),
distantParent: link.distantParent,
onSelect: { select(selectedID == snapshot.id ? nil : snapshot.id) },
onSelectParent: selectParent) {
SnapshotThumbnail(image: snapshot.screenshot, isCompact: isCompact)
} title: {
HStack(alignment: .firstTextBaseline) {
SnapshotTitle(title: snapshot.title, isEditing: renamingID == snapshot.id) { title in
if let title = title {
perform {
try await list.rename(snapshot, to: title)
}
}
renamingID = nil
isTimelineFocused = selectedID != nil
}
if isCompact && snapshot.size > 0 {
Spacer(minLength: 8)
SnapshotDetails.size(of: snapshot)
.font(.subheadline)
.foregroundColor(.secondary)
.layoutPriority(1)
}
}
} details: {
if snapshot.isOrphaned {
Label("The data of this snapshot is missing.", systemImage: "exclamationmark.triangle.fill")
.labelStyle(.titleAndIcon)
.foregroundColor(.orange)
} else if snapshot.isIncomplete {
Label("This snapshot does not cover every drive.", systemImage: "exclamationmark.triangle.fill")
.labelStyle(.titleAndIcon)
.foregroundColor(.orange)
}
SnapshotDetails(snapshot: snapshot, isCompact: isCompact)
} actions: {
actions(for: snapshot)
}
.contextMenu {
menuActions(for: snapshot)
}
.confirmationDialog("Confirm", isPresented: isConfirming(snapshot.id), presenting: confirmation) { confirmation in
Button("Cancel", role: .cancel) {}
switch confirmation {
case .delete(_):
Button("Delete", role: .destructive) {
delete(snapshot)
}
case .restore(_):
Button("Restore", role: .destructive) {
restore(snapshot)
}
case .overwriteBase, .deleteSuspendState:
EmptyView()
}
} message: { confirmation in
switch confirmation {
case .delete(_):
Text("Do you want to delete the snapshot “\(snapshot.title)” and all its data?")
case .restore(_):
Text("Do you want to restore the snapshot “\(snapshot.title)”? The current state of the virtual machine will be overwritten and any changes that are not saved as a snapshot will be lost.")
case .overwriteBase, .deleteSuspendState:
EmptyView()
}
}
.id(snapshot.id)
}
}
.background(GeometryReader { geometry in
Color.clear.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
})
.onPreferenceChange(WidthPreferenceKey.self) { width in
self.width = width
}
.task(id: vm.state) {
// snapshots can only change while the VM does something
try? await list.refresh()
}
#if os(macOS)
.modifier(SnapshotKeyboardCommands(isFocused: $isTimelineFocused) {
if let snapshot = list.snapshot(for: selectedID), snapshot.canDelete {
confirmation = .delete(snapshot.id)
}
} onExit: {
select(nil)
})
#endif
}
private var emptyPlaceholder: some View {
VStack(spacing: 4) {
Text("No Snapshots")
.font(.headline)
Text("Save the current state of the virtual machine as a snapshot to return to it later.")
.font(.subheadline)
.multilineTextAlignment(.center)
}.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 32)
}
private var currentStatus: String {
if !vm.isStopped || vm.state == .paused {
return vm.stateLabel
} else if vm.hasSuspendState {
return NSLocalizedString("Suspended", comment: "VMSnapshotsView")
} else {
return NSLocalizedString("Not running", comment: "VMSnapshotsView")
}
}
/// - Parameter hasState: Whether the snapshot holds the running machine, `nil` for the current state
private func rail(for link: Link, hasState: Bool?) -> SnapshotRail {
SnapshotRail(marker: hasState.map { SnapshotRail.Marker.snapshot(hasState: $0) } ?? .current,
isInLineage: link.isInLineage,
up: link.connectsUp ? (link.isUpInLineage ? .lineage : .normal) : nil,
down: link.connectsDown ? (link.isInLineage ? .lineage : .normal) : nil,
hasDistantParent: link.distantParent != nil)
}
// MARK: Actions
/// The current state can be kept as a snapshot unless it already is one. Running the VM is
/// left to the rest of the window.
@ViewBuilder private var currentActions: some View {
if vm.hasSuspendState {
DestructiveButton {
confirmation = .deleteSuspendState
} label: {
Label("Delete", systemImage: "trash")
}.help("Delete the suspended state so the virtual machine starts up normally")
.disabled(!isReady)
}
if !list.isCurrentStateSaved {
if let baseSnapshot = baseSnapshot {
Button {
confirmation = .overwriteBase
} label: {
Label("Overwrite", systemImage: "square.and.arrow.down")
}.help("Replace the snapshot “\(baseSnapshot.title)” with the current state")
.disabled(!list.canOverwriteBase)
}
Button {
createSnapshot()
} label: {
Label("New", systemImage: "plus")
}.help("Save the current state as a new snapshot")
.buttonStyle(.borderedProminent)
.disabled(!list.canCreate)
}
}
@ViewBuilder private func actions(for snapshot: VMSnapshot) -> some View {
DestructiveButton {
confirmation = .delete(snapshot.id)
} label: {
Label("Delete", systemImage: "trash")
.labelStyle(.iconOnly)
}.help("Delete this snapshot")
.disabled(!snapshot.canDelete)
Button {
renamingID = snapshot.id
} label: {
Label("Rename", systemImage: "pencil")
.labelStyle(.iconOnly)
}.help("Rename this snapshot")
Button {
confirmation = .restore(snapshot.id)
} label: {
Label("Restore", systemImage: "arrow.uturn.backward")
}.help("Replace the current state with this snapshot")
.buttonStyle(.borderedProminent)
.disabled(!snapshot.canRestore)
}
@ViewBuilder private func menuActions(for snapshot: VMSnapshot) -> some View {
Button {
confirmation = .restore(snapshot.id)
} label: {
Label("Restore", systemImage: "arrow.uturn.backward")
}.disabled(!snapshot.canRestore)
Button {
select(snapshot.id)
renamingID = snapshot.id
} label: {
Label("Rename", systemImage: "pencil")
}
Divider()
DestructiveButton {
confirmation = .delete(snapshot.id)
} label: {
Label("Delete", systemImage: "trash")
}.disabled(!snapshot.canDelete)
}
/// - Parameter id: Snapshot the row is for, `nil` for the current state
private func isConfirming(_ id: UUID?) -> Binding<Bool> {
Binding {
confirmation != nil && confirmation?.id == id
} set: { isPresented in
if !isPresented && confirmation?.id == id {
confirmation = nil
}
}
}
/// Show that the VM is busy during `work` and show the error if it fails
private func perform(_ work: @escaping @MainActor @Sendable () async throws -> Void) {
data.busyWorkAsync {
try await work()
}
}
private func select(_ id: UUID?) {
guard selectedID != id else {
return
}
renamingID = nil
isTimelineFocused = id != nil
withAnimation(.easeInOut(duration: 0.2)) {
selectedID = id
}
}
private func selectParent(_ parent: VMSnapshot) {
select(parent.id)
scrollTo(parent.id)
}
private func createSnapshot() {
perform {
let id = try await list.createSnapshot()
// let the default title be replaced right away
selectedID = id
renamingID = id
}
}
private func overwriteBase() {
perform {
try await list.overwriteBase()
}
}
private func deleteSuspendState() {
perform {
try await list.deleteSuspendState()
}
}
private func delete(_ snapshot: VMSnapshot) {
perform {
try await list.delete(snapshot)
if selectedID == snapshot.id {
selectedID = nil
}
}
}
/// Replace the current state with the snapshot. A VM that is off resumes from it once it is run.
private func restore(_ snapshot: VMSnapshot) {
perform {
try await list.restore(snapshot)
selectedID = nil
}
}
}
#if os(macOS)
/// Delete and Escape for the selected snapshot.
///
/// The commands only reach a view that has focus. One that is not a control shows a focus ring
/// around all of it which cannot be turned off before macOS 14, so older versions go without.
private struct SnapshotKeyboardCommands: ViewModifier {
let isFocused: FocusState<Bool>.Binding
let onDelete: () -> Void
let onExit: () -> Void
func body(content: Content) -> some View {
if #available(macOS 14, *) {
content
.focusable()
.focusEffectDisabled()
.focused(isFocused)
.onDeleteCommand(perform: onDelete)
.onExitCommand(perform: onExit)
} else {
content
}
}
}
#endif
private struct WidthPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = nextValue() ?? value
}
}
// MARK: - Row
/// Shared layout of the current state and of a snapshot.
///
/// Only the focused row shows its actions to keep the rest of the list quiet. They sit on the
/// trailing edge where there is room and move below the text on narrow screens.
private struct SnapshotRow<Thumbnail: View, Title: View, Details: View, Actions: View>: View {
let isCompact: Bool
let isFocused: Bool
let isSelected: Bool
let rail: SnapshotRail
let distantParent: VMSnapshot?
let onSelect: () -> Void
let onSelectParent: (VMSnapshot) -> Void
@ViewBuilder let thumbnail: () -> Thumbnail
@ViewBuilder let title: () -> Title
@ViewBuilder let details: () -> Details
@ViewBuilder let actions: () -> Actions
@State private var isHovered: Bool = false
private static var verticalPadding: CGFloat { 8 }
private var thumbnailHeight: CGFloat {
SnapshotThumbnail.height(isCompact: isCompact)
}
private var showsActions: Bool {
isFocused || isHovered
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
thumbnail()
VStack(alignment: .leading, spacing: 2) {
title()
details()
.font(.subheadline)
.foregroundColor(.secondary)
if let parent = distantParent {
parentLink(to: parent)
}
}.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
if !isCompact {
// always laid out so that focusing a row does not reflow its text
actionBar
.frame(height: thumbnailHeight)
.opacity(showsActions ? 1 : 0)
.disabled(!showsActions)
.accessibilityHidden(!showsActions)
}
}
if isCompact && isFocused {
actionBar
.frame(maxWidth: .infinity, alignment: .trailing)
.transition(.opacity)
}
}.padding(.vertical, Self.verticalPadding)
.padding(.horizontal, 8)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(highlight))
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
.onHover { isHovered = $0 }
.padding(.leading, SnapshotRail.width)
.background(alignment: .leading) {
rail.markerCenter(Self.verticalPadding + thumbnailHeight / 2)
.frame(width: SnapshotRail.width)
}
.accessibilityElement(children: .contain)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var actionBar: some View {
HStack(spacing: 8) {
actions()
}.labelStyle(.titleAndIcon)
.buttonStyle(.bordered)
.controlSize(.small)
.fixedSize()
}
private var highlight: Color {
if isSelected {
return .accentColor.opacity(0.15)
} else if isHovered {
return .primary.opacity(0.05)
} else {
return .clear
}
}
/// Names the parent when the rail cannot lead to it because other snapshots are in between.
private func parentLink(to parent: VMSnapshot) -> some View {
Button {
onSelectParent(parent)
} label: {
Label {
Text("From “\(parent.title)”")
} icon: {
Image(systemName: "arrow.triangle.branch")
}.labelStyle(.titleAndIcon)
.font(.caption)
}.buttonStyle(.plain)
.foregroundColor(.accentColor)
.padding(.top, 2)
.help("Show the snapshot this one was created from")
}
}
// MARK: - Rail
/// Vertical line with a marker for each row. Rows that follow their parent are joined into one
/// uninterrupted line so that a lineage without branches reads as a plain timeline.
private struct SnapshotRail: View {
static let width: CGFloat = 20
static let lineWidth: CGFloat = 2
enum Marker {
case current
/// Solid when the snapshot holds the running machine, hollow when it holds only the disks
case snapshot(hasState: Bool)
}
enum Style {
case normal
case lineage
var color: Color {
switch self {
case .normal: return .secondary.opacity(0.35)
case .lineage: return .accentColor
}
}
}
struct Segment: View {
let style: Style
var body: some View {
Rectangle()
.fill(style.color)
.frame(width: SnapshotRail.lineWidth)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
static let markerSize: CGFloat = 14
let marker: Marker
let isInLineage: Bool
let up: Style?
let down: Style?
let hasDistantParent: Bool
/// Distance from the top of the row to the center of the marker
var markerCenter: CGFloat = 0
private var style: Style {
isInLineage ? .lineage : .normal
}
private var markerColor: Color {
style == .lineage ? .accentColor : .secondary.opacity(0.6)
}
var body: some View {
VStack(spacing: 0) {
segment(up)
.frame(height: max(0, markerCenter - Self.markerSize / 2))
markerView
.frame(width: Self.markerSize, height: Self.markerSize)
if hasDistantParent {
// loose end hinting that the lineage carries on further down
VStack(spacing: 0) {
Line()
.stroke(style.color, style: StrokeStyle(lineWidth: Self.lineWidth, lineCap: .round, dash: [1, 4]))
.frame(width: Self.lineWidth, height: 12)
Spacer(minLength: 0)
}.frame(maxHeight: .infinity)
} else {
segment(down)
}
}.accessibilityHidden(true)
}
func markerCenter(_ markerCenter: CGFloat) -> SnapshotRail {
var rail = self
rail.markerCenter = markerCenter
return rail
}
@ViewBuilder private func segment(_ style: Style?) -> some View {
if let style = style {
Segment(style: style)
} else {
Color.clear
}
}
@ViewBuilder private var markerView: some View {
switch marker {
case .current:
Circle()
.strokeBorder(Color.accentColor, lineWidth: Self.lineWidth)
.background(Circle().fill(Color.accentColor).padding(4))
case .snapshot(let hasState):
Group {
if hasState {
Circle().fill(markerColor)
} else {
Circle().strokeBorder(markerColor, lineWidth: 1.5)
}
}.frame(width: 9, height: 9)
}
}
private struct Line: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
return path
}
}
}
// MARK: - Row content
private struct SnapshotThumbnail: View {
let image: PlatformImage?
let isCompact: Bool
static func height(isCompact: Bool) -> CGFloat {
(isCompact ? 80 : 96) * 9 / 16
}
private var width: CGFloat {
Self.height(isCompact: isCompact) * 16 / 9
}
var body: some View {
ZStack {
if let image = image {
Color.black
#if os(macOS)
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
#else
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
#endif
} else {
Color.secondary.opacity(0.15)
Image(systemName: "display")
.font(.title2)
.foregroundColor(.secondary.opacity(0.6))
}
}.frame(width: width, height: Self.height(isCompact: isCompact))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).strokeBorder(Color.primary.opacity(0.1)))
.accessibilityHidden(true)
}
}
private struct SnapshotTitle: View {
let title: String
let isEditing: Bool
/// Called with the new title or `nil` if editing was cancelled
let onCommit: (String?) -> Void
@State private var draft: String = ""
@FocusState private var isFocused: Bool
var body: some View {
if isEditing {
TextField("Name", text: $draft)
.font(.headline)
.textFieldStyle(.roundedBorder)
.focused($isFocused)
.onSubmit {
onCommit(draft)
}
#if os(macOS)
.onExitCommand {
onCommit(nil)
}
#endif
.onChange(of: isFocused) { isFocused in
if !isFocused {
onCommit(draft)
}
}
.onAppear {
draft = title
// focus is not accepted until the field has settled in the hierarchy,
// which takes a moment on older systems
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
isFocused = true
}
}
} else {
Text(title)
.font(.headline)
}
}
}
private struct SnapshotDetails: View {
let snapshot: VMSnapshot
/// The size is shown next to the title when there is no room for it here
let isCompact: Bool
static func size(of snapshot: VMSnapshot) -> Text {
Text(ByteCountFormatter.string(fromByteCount: snapshot.size, countStyle: .binary))
}
/// Not every backend knows how much the disks of a snapshot occupy
private var size: Text? {
snapshot.size > 0 ? Self.size(of: snapshot) : nil
}
/// Note for a snapshot that the VM starts up from instead of resuming
private var kind: Text? {
snapshot.hasState ? nil : Text("Disks Only")
}
private func joined(_ parts: Text?...) -> Text {
let parts = parts.compactMap { $0 }
guard let first = parts.first else {
return Text(verbatim: "")
}
return parts.dropFirst().reduce(first) { $0 + separator + $1 }
}
private func created(_ style: Date.FormatStyle.DateStyle) -> Text {
Text("Created \(format(snapshot.dateCreated, style))")
}
private func modified(_ style: Date.FormatStyle.DateStyle) -> Text {
Text("Modified \(format(snapshot.dateModified, style))")
}
/// The year is only worth its space when it is not the current one.
private func format(_ date: Date, _ style: Date.FormatStyle.DateStyle) -> String {
if style == .abbreviated && Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year) {
return date.formatted(.dateTime.month(.abbreviated).day().hour().minute())
} else {
return date.formatted(date: style, time: .shortened)
}
}
private var separator: Text {
Text(verbatim: " · ")
}
var body: some View {
if isCompact {
VStack(alignment: .leading, spacing: 2) {
created(.abbreviated)
modified(.abbreviated)
if let kind = kind {
kind
}
}
} else if #available(iOS 16, macOS 13, *) {
ViewThatFits(in: .horizontal) {
joined(created(.abbreviated), modified(.abbreviated), size, kind)
stacked
}
} else {
stacked
}
}
/// Both lines end up about as long as each other
private var stacked: some View {
VStack(alignment: .leading, spacing: 2) {
joined(created(.abbreviated), kind)
joined(modified(.abbreviated), size)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
//
// Copyright © 2026 Turing Software, LLC. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
/// A saved state of a virtual machine as shown in the snapshots list.
struct VMSnapshot: Identifiable, Equatable {
let id: UUID
/// User-editable display name
var title: String
/// Snapshot the current state was based on when this one was created, `nil` if there was none
var parentID: UUID?
var dateCreated: Date
/// Last time the snapshot was renamed or overwritten
var dateModified: Date
/// Bytes occupied on disk
var size: Int64
/// Screen contents when the snapshot was taken, if one was captured
var screenshot: PlatformImage?
/// Includes the running state of the VM, otherwise it starts up from the saved disks
var hasState: Bool
/// The saved state is missing so the snapshot can only be deleted
var isOrphaned: Bool
/// The saved state does not cover every drive so the snapshot cannot be restored
var isIncomplete: Bool = false
/// Possible while the VM is in its current state, which differs between running and not
var canRestore: Bool = true
/// Possible while the VM is in its current state, which differs between running and not
var canDelete: Bool = true
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
&& lhs.title == rhs.title
&& lhs.parentID == rhs.parentID
&& lhs.dateCreated == rhs.dateCreated
&& lhs.dateModified == rhs.dateModified
&& lhs.size == rhs.size
&& lhs.screenshot === rhs.screenshot
&& lhs.hasState == rhs.hasState
&& lhs.isOrphaned == rhs.isOrphaned
&& lhs.isIncomplete == rhs.isIncomplete
&& lhs.canRestore == rhs.canRestore
&& lhs.canDelete == rhs.canDelete
}
}
/// Snapshots of a single VM along with the "current" state they are listed under.
///
/// FIXME: all operations are placeholders that only modify the in-memory list.
@MainActor
final class VMSnapshotList: ObservableObject {
/// Saved snapshots, newest first
@Published private(set) var snapshots: [VMSnapshot] = []
/// Snapshot the current state is based on, `nil` if there is none
@Published private(set) var currentParentID: UUID?
/// Nothing changed since the snapshot the current state is based on
@Published private(set) var isCurrentStateSaved: Bool = false
/// The current state can be saved while the VM is in its current state
@Published private(set) var canCreate: Bool = true
/// The snapshot the current state is based on can be replaced while the VM is in its current state
var canOverwriteBase: Bool {
canCreate && snapshot(for: currentParentID)?.canDelete == true
}
private let vm: VMData
private static var cache: [UUID: VMSnapshotList] = [:]
/// Returns the list for a VM, keeping it alive so placeholder edits survive navigation.
static func list(for vm: VMData) -> VMSnapshotList {
if let list = cache[vm.id] {
return list
}
let list = VMSnapshotList(vm: vm)
cache[vm.id] = list
return list
}
private init(vm: VMData) {
self.vm = vm
}
// MARK: - Lineage
func snapshot(for id: UUID?) -> VMSnapshot? {
guard let id = id else {
return nil
}
return snapshots.first { $0.id == id }
}
/// Identifiers of every ancestor starting at `id` (inclusive) and walking up the parents.
func lineage(from id: UUID?) -> Set<UUID> {
var lineage = Set<UUID>()
var next = snapshot(for: id)
while let snapshot = next, !lineage.contains(snapshot.id) {
lineage.insert(snapshot.id)
next = self.snapshot(for: snapshot.parentID)
}
return lineage
}
// MARK: - Operations
/// FIXME: read the snapshots from the VM
func refresh() async throws {
}
func rename(_ snapshot: VMSnapshot, to title: String) async throws {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty, let index = snapshots.firstIndex(where: { $0.id == snapshot.id }) else {
return
}
snapshots[index].title = title
snapshots[index].dateModified = Date()
}
/// Saves the current state as a new snapshot based on the one the current state was based on.
/// - Returns: Identifier of the new snapshot
@discardableResult
func createSnapshot() async throws -> UUID {
logger.debug("FIXME: saving the current state as a snapshot is not implemented")
let now = Date()
let snapshot = VMSnapshot(id: UUID(),
title: defaultTitle,
parentID: currentParentID,
dateCreated: now,
dateModified: now,
size: 0,
screenshot: vm.screenshotImage,
hasState: vm.hasSuspendState,
isOrphaned: false)
snapshots.insert(snapshot, at: 0)
currentParentID = snapshot.id
return snapshot.id
}
/// Replaces what the snapshot the current state is based on holds with the current state.
func overwriteBase() async throws {
logger.debug("FIXME: overwriting a snapshot is not implemented")
guard let index = snapshots.firstIndex(where: { $0.id == currentParentID }) else {
return
}
snapshots[index].dateModified = Date()
snapshots[index].screenshot = vm.screenshotImage
snapshots[index].hasState = vm.hasSuspendState
}
/// Snapshots based on a deleted snapshot are handed to what it was based on so the remaining lineage stays linked.
func delete(_ snapshot: VMSnapshot) async throws {
logger.debug("FIXME: deleting a snapshot is not implemented")
for index in snapshots.indices where snapshots[index].parentID == snapshot.id {
snapshots[index].parentID = snapshot.parentID
}
if currentParentID == snapshot.id {
currentParentID = snapshot.parentID
}
snapshots.removeAll { $0.id == snapshot.id }
}
/// Replaces the current state with `snapshot`. If the VM is not running, it resumes from it when started.
func restore(_ snapshot: VMSnapshot) async throws {
logger.debug("FIXME: restoring a snapshot is not implemented")
currentParentID = snapshot.id
}
/// Discards the state the VM is suspended to so that it starts up normally.
func deleteSuspendState() async throws {
logger.debug("FIXME: deleting the suspended state is not implemented")
}
private var defaultTitle: String {
var number = snapshots.count + 1
var title: String
repeat {
title = String.localizedStringWithFormat(NSLocalizedString("Snapshot %lld", comment: "VMSnapshotData"), number)
number += 1
} while snapshots.contains { $0.title == title }
return title
}
}
+16
View File
@@ -168,8 +168,11 @@
8471772927CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
8471772A27CD3CAB00D3A50B /* DetailedSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471772727CD3CAB00D3A50B /* DetailedSection.swift */; };
847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847BF9A92A49C783000BD9AA /* VMData.swift */; };
A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */; };
84818C0C2898A07A009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
84818C0D2898A07F009EDB67 /* AVFAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84818C0B2898A07A009EDB67 /* AVFAudio.framework */; };
848308D5278A1F2200E3E474 /* Virtualization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 848308D4278A1F2200E3E474 /* Virtualization.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
@@ -615,7 +618,9 @@
CE2D956A24AD4F990059923A /* VMPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954224AD4F980059923A /* VMPlaceholderView.swift */; };
CE2D956C24AD4F990059923A /* VMCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954324AD4F980059923A /* VMCardView.swift */; };
CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CE2D957124AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; };
CE2D957224AD4F990059923A /* UTMExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954624AD4F980059923A /* UTMExtensions.swift */; };
CE2D957324AD4F990059923A /* VMConfigSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954724AD4F980059923A /* VMConfigSharingView.swift */; };
@@ -752,6 +757,7 @@
CEA3EE05301691C500AAA8E4 /* QEMURenderServer.app in Embed Executables */ = {isa = PBXBuildFile; fileRef = CEA3EDFA30168B9000AAA8E4 /* QEMURenderServer.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */ = {isa = PBXBuildFile; fileRef = 83FBDD53242FA71900D2C5D7 /* VMDisplayMetalViewController+Pointer.h */; };
CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */; };
E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */; };
CEA45E37263519B5002FA97D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE772AAB25C8B0F600E4E379 /* ContentView.swift */; };
CEA45E3A263519B5002FA97D /* UTMLegacyQemuConfiguration+System.m in Sources */ = {isa = PBXBuildFile; fileRef = CE5425332437C22A00E520F7 /* UTMLegacyQemuConfiguration+System.m */; };
CEA45E3F263519B5002FA97D /* UTMProcess.m in Sources */ = {isa = PBXBuildFile; fileRef = CE9D197B226542FE00355E14 /* UTMProcess.m */; };
@@ -1796,6 +1802,7 @@
8471770527CC974F00D3A50B /* DefaultTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextField.swift; sourceTree = "<group>"; };
8471772727CD3CAB00D3A50B /* DetailedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedSection.swift; sourceTree = "<group>"; };
847BF9A92A49C783000BD9AA /* VMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMData.swift; sourceTree = "<group>"; };
16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSnapshotData.swift; sourceTree = "<group>"; };
84818C0B2898A07A009EDB67 /* AVFAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFAudio.framework; path = System/Library/Frameworks/AVFAudio.framework; sourceTree = SDKROOT; };
848308D4278A1F2200E3E474 /* Virtualization.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Virtualization.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.1.sdk/System/Library/Frameworks/Virtualization.framework; sourceTree = DEVELOPER_DIR; };
848A98AF286A0F74006F0550 /* UTMAppleConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMAppleConfiguration.swift; sourceTree = "<group>"; };
@@ -1987,6 +1994,7 @@
CE2D954224AD4F980059923A /* VMPlaceholderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMPlaceholderView.swift; sourceTree = "<group>"; };
CE2D954324AD4F980059923A /* VMCardView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMCardView.swift; sourceTree = "<group>"; };
CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMRemovableDrivesView.swift; sourceTree = "<group>"; };
B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMSnapshotsView.swift; sourceTree = "<group>"; };
CE2D954624AD4F980059923A /* UTMExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UTMExtensions.swift; sourceTree = "<group>"; };
CE2D954724AD4F980059923A /* VMConfigSharingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigSharingView.swift; sourceTree = "<group>"; };
CE2D954824AD4F980059923A /* VMConfigInputView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VMConfigInputView.swift; sourceTree = "<group>"; };
@@ -2786,6 +2794,7 @@
835AA7B026AB7C85007A0411 /* UTMPendingVirtualMachine.swift */,
CE611BE629F50CAD001817BC /* UTMReleaseHelper.swift */,
847BF9A92A49C783000BD9AA /* VMData.swift */,
16001E3BC682D8470EFCF312 /* VMSnapshotData.swift */,
CE550BD52259479D0063E575 /* Assets.xcassets */,
521F3EFB2414F73800130500 /* Localizable.strings */,
CED8DF7928A120C100C34345 /* Localizable.stringsdict */,
@@ -3143,6 +3152,7 @@
8432328F28C2CDAD00CFBC97 /* VMNavigationListView.swift */,
CE2D954224AD4F980059923A /* VMPlaceholderView.swift */,
CE2D954524AD4F980059923A /* VMRemovableDrivesView.swift */,
B5BB911D9C7D6564198DAC87 /* VMSnapshotsView.swift */,
84C4D9012880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift */,
CE8813D424CD265700532628 /* VMShareFileModifier.swift */,
CE2D953824AD4F980059923A /* VMToolbarModifier.swift */,
@@ -3807,10 +3817,12 @@
CE231D462BDDFD03006D6DC3 /* UTMDonateStore.swift in Sources */,
84C4D9022880CA8A00EC3B2B /* VMSettingsAddDeviceMenuView.swift in Sources */,
CE2D956F24AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
F3B87AB8E7FA8B9648FC0EAD /* VMSnapshotsView.swift in Sources */,
8443EFF22845641600B2E6E2 /* UTMQemuConfigurationDrive.swift in Sources */,
8443EFFA28456F3B00B2E6E2 /* UTMQemuConfigurationSharing.swift in Sources */,
CE772AAC25C8B0F600E4E379 /* ContentView.swift in Sources */,
847BF9AA2A49C783000BD9AA /* VMData.swift in Sources */,
29B2D623BB879D2653CA8C53 /* VMSnapshotData.swift in Sources */,
CE2D927A24AD46670059923A /* UTMLegacyQemuConfiguration+System.m in Sources */,
843BF8302844853E0029D60D /* UTMQemuConfigurationNetwork.swift in Sources */,
CE2D927C24AD46670059923A /* UTMProcess.m in Sources */,
@@ -4135,6 +4147,7 @@
CEFE96772B69A7CC000F00C9 /* VMRemoteSessionState.swift in Sources */,
CEEF26A72CEDAEEA003F7B8C /* UTMDownloadMacSupportToolsTask.swift in Sources */,
CE2D957024AD4F990059923A /* VMRemovableDrivesView.swift in Sources */,
D38CFF45AB0566027A30677F /* VMSnapshotsView.swift in Sources */,
CE25124B29BFE273000790AB /* UTMScriptable.swift in Sources */,
CE0B6CFE24AD56AE00FE012D /* UTMLogging.m in Sources */,
843BF82E284482C10029D60D /* UTMQemuConfigurationInput.swift in Sources */,
@@ -4182,6 +4195,7 @@
CE9B153F2B11A63E003A32DD /* UTMRemoteServer.swift in Sources */,
846F8D5A2E3891FE0037162B /* UTMKeyboardShortcuts.swift in Sources */,
847BF9AC2A49C783000BD9AA /* VMData.swift in Sources */,
A5A9A26C444286951EC315D9 /* VMSnapshotData.swift in Sources */,
CE25124729BFDB87000790AB /* UTMScriptingGuestProcessImpl.swift in Sources */,
CE2D958824AD4F990059923A /* VMConfigPortForwardForm.swift in Sources */,
03FA9C752B9BBDB000C53A5A /* UTMConfigurationHostNetwork.swift in Sources */,
@@ -4207,6 +4221,7 @@
CEA45E25263519B5002FA97D /* VMDisplayMetalViewController+Pointer.h in Sources */,
84C505AD28C588EC007CE8FF /* SizeTextField.swift in Sources */,
CEA45E27263519B5002FA97D /* VMRemovableDrivesView.swift in Sources */,
E427638537FF5935C6DCAF11 /* VMSnapshotsView.swift in Sources */,
CEF0306E26A2AFDF00667B63 /* VMWizardOSView.swift in Sources */,
CE231D432BDDF280006D6DC3 /* UTMDonateView.swift in Sources */,
CE65BABF26A4D8DD0001BD6B /* VMConfigDisplayConsoleView.swift in Sources */,
@@ -4345,6 +4360,7 @@
CEF0304F26A2AFBF00667B63 /* BigButtonStyle.swift in Sources */,
84018691288A73300050AC51 /* VMDisplayViewController.m in Sources */,
847BF9AB2A49C783000BD9AA /* VMData.swift in Sources */,
10488F379FF913D2E7770E60 /* VMSnapshotData.swift in Sources */,
84909A8E27CACD5C005605F1 /* UTMPlaceholderVMView.swift in Sources */,
CEA45EF4263519B5002FA97D /* VMConfigSoundView.swift in Sources */,
8432329928C3017F00CFBC97 /* GlobalFileImporter.swift in Sources */,