From 0626690aa4fad492ba9adb4b68b19ad5d1f65e80 Mon Sep 17 00:00:00 2001 From: Ollie Munday Date: Sat, 21 Feb 2026 23:22:05 +0000 Subject: [PATCH] Add groups to VM list --- Platform/Shared/VMContextMenuModifier.swift | 30 ++ Platform/Shared/VMNavigationListView.swift | 309 ++++++++++++- Platform/UTMData.swift | 456 +++++++++++++++++++- 3 files changed, 773 insertions(+), 22 deletions(-) diff --git a/Platform/Shared/VMContextMenuModifier.swift b/Platform/Shared/VMContextMenuModifier.swift index 08bd12c46..157a4fce2 100644 --- a/Platform/Shared/VMContextMenuModifier.swift +++ b/Platform/Shared/VMContextMenuModifier.swift @@ -151,6 +151,36 @@ struct VMContextMenuModifier: ViewModifier { Label("New from template…", systemImage: "doc.on.clipboard") }.help("Create a new VM with the same configuration as this one but without any data.") Divider() + Menu { + let groups = data.sortedGroups + let activeGroupID = data.groupContaining(vmID: vm.id)?.id + Button("New Group…") { + data.requestCreateGroup(assigning: vm.id) + } + if !groups.isEmpty { + Divider() + } + ForEach(groups) { group in + Button { + data.addVM(vmID: vm.id, toGroupID: group.id) + } label: { + if activeGroupID == group.id { + Label(group.title, systemImage: "checkmark") + } else { + Text(group.title) + } + } + } + if activeGroupID != nil { + Divider() + Button("Remove from Group") { + data.removeVMFromGroup(vm.id) + } + } + } label: { + Label("Group", systemImage: "folder") + } + Divider() if vm.isShortcut { DestructiveButton { confirmAction = .confirmDeleteShortcut diff --git a/Platform/Shared/VMNavigationListView.swift b/Platform/Shared/VMNavigationListView.swift index 32d8cbe36..566b098a7 100644 --- a/Platform/Shared/VMNavigationListView.swift +++ b/Platform/Shared/VMNavigationListView.swift @@ -23,7 +23,7 @@ struct VMNavigationListView: View { var body: some View { if #available(iOS 16, macOS 13, *) { CompatibleNavigationSplitView { - List(selection: $data.selectedVM) { + List(selection: $data.selectedSidebarItem) { listBody }.modifier(VMListModifier()) } detail: { @@ -49,21 +49,23 @@ struct VMNavigationListView: View { } @ViewBuilder private var listBody: some View { - ForEach(data.virtualMachines) { vm in - if !vm.isLoaded { - UTMUnavailableVMView(vm: vm) - } else { - if #available(iOS 16, macOS 13, visionOS 1, *) { - VMCardView(vm: vm) - .modifier(VMContextMenuModifier(vm: vm)) - .tag(vm) - } else { - NavigationLink( - destination: VMDetailsView(vm: vm), - tag: vm, - selection: $data.selectedVM, - label: { VMCardView(vm: vm) }) - .modifier(VMContextMenuModifier(vm: vm)) + ForEach(data.sidebarItems, id: \.id) { item in + switch item { + case .vm(let vmID): + if let vm = data.vm(for: vmID) { + vmRow(vm: vm) + } + case .group(let groupID): + if let group = data.group(for: groupID) { + groupHeaderRow(group) + if group.isExpanded { + ForEach(data.virtualMachines(inGroup: group.id)) { vm in + vmRow(vm: vm) + .padding(.leading, 18) + }.onMove { fromOffsets, toOffset in + moveInGroup(groupID: group.id, fromOffsets: fromOffsets, toOffset: toOffset) + } + } } } }.onMove(perform: move) @@ -81,14 +83,28 @@ struct VMNavigationListView: View { } private func move(fromOffsets: IndexSet, toOffset: Int) { - data.listMove(fromOffsets: fromOffsets, toOffset: toOffset) + data.moveSidebarItems(fromOffsets: fromOffsets, toOffset: toOffset) + } + + private func moveInGroup(groupID: UUID, fromOffsets: IndexSet, toOffset: Int) { + data.moveVMs(inGroup: groupID, fromOffsets: fromOffsets, toOffset: toOffset) } private func delete(indexSet: IndexSet) { - let selected = data.virtualMachines[indexSet] - for vm in selected { - data.busyWorkAsync { - try await data.delete(vm: vm) + for index in indexSet { + guard index < data.sidebarItems.count else { + continue + } + switch data.sidebarItems[index] { + case .vm(let vmID): + guard let vm = data.vm(for: vmID) else { + continue + } + data.busyWorkAsync { + try await data.delete(vm: vm) + } + case .group(let groupID): + data.deleteGroup(id: groupID) } } } @@ -99,6 +115,45 @@ struct VMNavigationListView: View { data.cancelDownload(for: vm) } } + + @ViewBuilder private func vmRow(vm: VMData) -> some View { + if !vm.isLoaded { + UTMUnavailableVMView(vm: vm) + } else { + if #available(iOS 16, macOS 13, visionOS 1, *) { + VMCardView(vm: vm) + .modifier(VMContextMenuModifier(vm: vm)) + .tag(VMSidebarSelection.vm(vm.id)) + } else { + NavigationLink( + destination: VMDetailsView(vm: vm), + tag: VMSidebarSelection.vm(vm.id), + selection: $data.selectedSidebarItem, + label: { VMCardView(vm: vm) }) + .modifier(VMContextMenuModifier(vm: vm)) + } + } + } + + @ViewBuilder private func groupHeaderRow(_ group: VMGroup) -> some View { + VMGroupRow(group: group) { + data.toggleGroupExpanded(id: group.id) + } + .contentShape(Rectangle()) + .contextMenu { + Button(group.isExpanded ? "Collapse" : "Expand") { + data.toggleGroupExpanded(id: group.id) + } + Button("Rename Group…") { + data.requestRenameGroup(id: group.id) + } + DestructiveButton { + data.deleteGroup(id: group.id) + } label: { + Label("Delete Group", systemImage: "trash") + } + } + } } @available(iOS 16, macOS 13, *) @@ -162,6 +217,9 @@ private struct VMListModifier: ViewModifier { ToolbarItem(placement: .navigation) { newButton } + ToolbarItem(placement: .navigation) { + createGroupButton + } #else #if !WITH_REMOTE // FIXME: implement remote feature ToolbarItem(placement: .navigationBarLeading) { @@ -177,6 +235,9 @@ private struct VMListModifier: ViewModifier { newButton } } + ToolbarItem(placement: .navigationBarLeading) { + createGroupButton + } #endif #if !WITH_REMOTE ToolbarItem(placement: .navigationBarLeading) { @@ -213,11 +274,16 @@ private struct VMListModifier: ViewModifier { } #endif } + #if os(macOS) + .onMoveCommand(perform: handleMoveCommand) + #endif #if os(iOS) // SwiftUI bug on iOS 14.4 and previous versions prevents multiple .sheet from working .sheet(isPresented: $sheetPresented) { if data.showNewVMSheet { VMWizardView() + } else if data.showGroupEditorSheet { + VMGroupEditorSheet() } else if settingsPresented { #if !WITH_REMOTE UTMSettingsView() @@ -238,6 +304,15 @@ private struct VMListModifier: ViewModifier { .onChange(of: settingsPresented) { newValue in if newValue { data.showNewVMSheet = false + data.showGroupEditorSheet = false + donatePresented = false + sheetPresented = true + } + } + .onChange(of: data.showGroupEditorSheet) { newValue in + if newValue && !supportsGroupEditorAlert { + data.showNewVMSheet = false + settingsPresented = false donatePresented = false sheetPresented = true } @@ -245,6 +320,7 @@ private struct VMListModifier: ViewModifier { .onChange(of: donatePresented) { newValue in if newValue { data.showNewVMSheet = false + data.showGroupEditorSheet = false settingsPresented = false sheetPresented = true } @@ -254,8 +330,18 @@ private struct VMListModifier: ViewModifier { settingsPresented = false donatePresented = false data.showNewVMSheet = false + if !supportsGroupEditorAlert { + data.showGroupEditorSheet = false + } } } + .modifier(GroupEditorAlertCompatModifier( + isPresented: groupEditorAlertBinding, + title: isEditingGroup ? "Rename Group" : "New Group", + text: $data.groupEditorTitle, + onCancel: { data.cancelGroupEditorChanges() }, + onSave: { data.commitGroupEditorChanges() } + )) .onReceive(NSNotification.OpenVirtualMachine) { _ in sheetPresented = false } @@ -263,6 +349,9 @@ private struct VMListModifier: ViewModifier { .sheet(isPresented: $data.showNewVMSheet) { VMWizardView() } + .sheet(isPresented: $data.showGroupEditorSheet) { + VMGroupEditorSheet() + } #if !os(macOS) && !WITH_REMOTE .sheet(isPresented: $donatePresented) { UTMDonateView() @@ -279,4 +368,182 @@ private struct VMListModifier: ViewModifier { Label("New VM", systemImage: "plus").labelStyle(.iconOnly) }).help("Create a new VM") } + + private var createGroupButton: some View { + Button(action: { data.requestCreateGroup() }, label: { + Label("New Group", systemImage: "folder.badge.plus").labelStyle(.iconOnly) + }).help("Create a new group") + } + + #if os(iOS) + private var supportsGroupEditorAlert: Bool { + if #available(iOS 15, *) { + return true + } else { + return false + } + } + + private var isEditingGroup: Bool { + data.editingGroupID != nil + } + + private var groupEditorAlertBinding: Binding { + Binding { + supportsGroupEditorAlert && data.showGroupEditorSheet + } set: { newValue in + if !newValue { + data.cancelGroupEditorChanges() + } else { + data.showGroupEditorSheet = true + } + } + } + #endif + + #if os(macOS) + private func handleMoveCommand(_ direction: MoveCommandDirection) { + guard case .group(let groupID) = data.selectedSidebarItem else { + return + } + if direction == .left { + data.collapseGroup(id: groupID) + } else if direction == .right { + data.expandGroup(id: groupID) + } + } + #endif +} + +#if os(iOS) +private struct GroupEditorAlertCompatModifier: ViewModifier { + @Binding var isPresented: Bool + let title: String + @Binding var text: String + let onCancel: () -> Void + let onSave: () -> Void + + func body(content: Content) -> some View { + if #available(iOS 15, *) { + content.alert(title, isPresented: $isPresented) { + TextField("Group Name", text: $text) + Button("Cancel", action: onCancel) + Button("Save", action: onSave) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("Enter a group name.") + } + } else { + content + } + } +} +#endif + +private struct VMGroupRow: View { + let group: VMGroup + let toggleExpand: () -> Void + + var body: some View { + HStack { + Button(action: toggleExpand) { + Image(systemName: group.isExpanded ? "chevron.down" : "chevron.right") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 16, height: 16) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .padding(.trailing, 2) + Text(group.title) + .font(.headline) + Spacer() + } + .padding([.top, .bottom], 10) + .accessibilityLabel(group.title) + .accessibilityValue(group.isExpanded ? Text("Expanded") : Text("Collapsed")) + } +} + +private struct VMGroupEditorSheet: View { + @EnvironmentObject private var data: UTMData + @Environment(\.presentationMode) private var presentationMode + + private var isEditing: Bool { + data.editingGroupID != nil + } + + private var isSaveDisabled: Bool { + data.groupEditorTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + #if os(macOS) + VStack(alignment: .leading, spacing: 14) { + Text(isEditing ? "Rename Group" : "New Group") + .font(.headline) + TextField("Group Name", text: $data.groupEditorTitle) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel") { + data.cancelGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + Button("Save") { + data.commitGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(isSaveDisabled) + } + } + .padding(20) + .frame(minWidth: 420, idealWidth: 460) + #elseif os(iOS) + VStack(alignment: .leading, spacing: 14) { + Text(isEditing ? "Rename Group" : "New Group") + .font(.headline) + TextField("Group Name", text: $data.groupEditorTitle) + .textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("Cancel") { + data.cancelGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + Button("Save") { + data.commitGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + .disabled(isSaveDisabled) + } + } + .padding(20) + #else + NavigationView { + Form { + Section { + TextField("Group Name", text: $data.groupEditorTitle) + } + } + .navigationTitle(isEditing ? "Rename Group" : "New Group") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + data.cancelGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + data.commitGroupEditorChanges() + presentationMode.wrappedValue.dismiss() + } + .disabled(isSaveDisabled) + } + } + } + #endif + } } diff --git a/Platform/UTMData.swift b/Platform/UTMData.swift index a6223d415..c3edc7c90 100644 --- a/Platform/UTMData.swift +++ b/Platform/UTMData.swift @@ -53,6 +53,73 @@ enum AlertItem: Identifiable { } } +struct VMGroup: Codable, Identifiable, Hashable { + var id: UUID + var title: String + var vmIDs: [UUID] + var isExpanded: Bool +} + +enum VMSidebarItem: Hashable, Codable { + case vm(UUID) + case group(UUID) + + private enum ItemType: String, Codable { + case vm + case group + } + + private enum CodingKeys: String, CodingKey { + case type + case id + } + + var id: String { + switch self { + case .vm(let uuid): + return "vm:\(uuid.uuidString)" + case .group(let uuid): + return "group:\(uuid.uuidString)" + } + } + + var uuid: UUID { + switch self { + case .vm(let uuid), .group(let uuid): + return uuid + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(ItemType.self, forKey: .type) + let id = try container.decode(UUID.self, forKey: .id) + switch type { + case .vm: + self = .vm(id) + case .group: + self = .group(id) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .vm(let id): + try container.encode(ItemType.vm, forKey: .type) + try container.encode(id, forKey: .id) + case .group(let id): + try container.encode(ItemType.group, forKey: .type) + try container.encode(id, forKey: .id) + } + } +} + +enum VMSidebarSelection: Hashable { + case vm(UUID) + case group(UUID) +} + @MainActor class UTMData: ObservableObject { /// Sandbox location for storing .utm bundles @@ -76,7 +143,18 @@ enum AlertItem: Identifiable { @Published var busyProgress: Float? /// View: currently selected VM - @Published var selectedVM: VMData? + @Published var selectedVM: VMData? { + didSet { + syncSidebarSelectionFromSelectedVM() + } + } + + /// View: currently selected sidebar item + @Published var selectedSidebarItem: VMSidebarSelection? { + didSet { + syncSelectedVMFromSidebarSelection() + } + } /// View: all VMs listed, we save a bookmark to each when array is modified @Published private(set) var virtualMachines: [VMData] { @@ -88,6 +166,20 @@ enum AlertItem: Identifiable { /// View: all pending VMs listed (ZIP and IPSW downloads) @Published private(set) var pendingVMs: [UTMPendingVirtualMachine] + /// View: all VM groups listed + @Published private(set) var vmGroups: [UUID: VMGroup] { + didSet { + sidebarSaveToDefaults() + } + } + + /// View: top-level sidebar order, mixing groups and ungrouped VMs + @Published private(set) var sidebarItems: [VMSidebarItem] { + didSet { + sidebarSaveToDefaults() + } + } + #if os(macOS) /// View controller for every VM currently active var vmWindows: [VMData: Any] = [:] @@ -123,14 +215,32 @@ enum AlertItem: Identifiable { /// Queue to run `busyWork` tasks private var busyQueue: DispatchQueue + @Published var showGroupEditorSheet: Bool + @Published var groupEditorTitle: String + @Published private(set) var editingGroupID: UUID? + private(set) var pendingGroupAssignmentVMID: UUID? + + private var isLoadingSidebar = false + private var isSyncingSidebarSelection = false + private var isUpdatingGroupExpansion = false + + private let groupExpansionStateDefaultsPrefix = "VMGroupExpansionState." + init() { self.busyQueue = DispatchQueue(label: "UTM Busy Queue", qos: .userInitiated) self.showSettingsModal = false self.showNewVMSheet = false + self.showGroupEditorSheet = false + self.groupEditorTitle = "" + self.editingGroupID = nil + self.pendingGroupAssignmentVMID = nil self.busy = false self.virtualMachines = [] self.pendingVMs = [] + self.vmGroups = [:] + self.sidebarItems = [] self.selectedVM = nil + self.selectedSidebarItem = nil #if WITH_SERVER self.remoteServer = UTMRemoteServer(data: self) beginObservingChanges() @@ -204,6 +314,10 @@ enum AlertItem: Identifiable { /// Load VM list (and order) from persistent storage fileprivate func listLoadFromDefaults() { + isLoadingSidebar = true + defer { + isLoadingSidebar = false + } let defaults = UserDefaults.standard guard defaults.object(forKey: "VMList") == nil else { listLegacyLoadFromDefaults() @@ -215,10 +329,14 @@ enum AlertItem: Identifiable { } // delete legacy defaults.removeObject(forKey: "VMList") + sidebarLoadFromDefaults() + sidebarReconcileWithVMs() return } // registry entry list guard let list = defaults.stringArray(forKey: "VMEntryList") else { + sidebarLoadFromDefaults() + sidebarReconcileWithVMs() return } let virtualMachines: [VMData] = list.uniqued().compactMap { uuidString in @@ -234,6 +352,8 @@ enum AlertItem: Identifiable { return vm } listReplace(with: virtualMachines) + sidebarLoadFromDefaults() + sidebarReconcileWithVMs() } /// Load VM list (and order) from persistent storage (legacy) @@ -276,12 +396,55 @@ enum AlertItem: Identifiable { defaults.set(wrappedVMs, forKey: "VMEntryList") } + private func sidebarLoadFromDefaults() { + let defaults = UserDefaults.standard + var groupsByID: [UUID: VMGroup] = [:] + var loadedSidebarItems: [VMSidebarItem] = [] + if let groupsData = defaults.data(forKey: "VMGroups"), + let groups = try? JSONDecoder().decode([VMGroup].self, from: groupsData) { + groupsByID = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0) }) + } + if let sidebarData = defaults.data(forKey: "VMSidebarItems"), + let sidebarItems = try? JSONDecoder().decode([VMSidebarItem].self, from: sidebarData) { + loadedSidebarItems = sidebarItems + } + groupsByID = groupsByID.mapValues { group in + var mutable = group + let expansionKey = groupExpansionStateDefaultsPrefix + group.id.uuidString + if defaults.object(forKey: expansionKey) != nil { + let isExpanded = defaults.bool(forKey: expansionKey) + mutable.isExpanded = isExpanded + } + return mutable + } + vmGroups = groupsByID + sidebarItems = loadedSidebarItems + } + + private func sidebarSaveToDefaults() { + guard !isLoadingSidebar && !isUpdatingGroupExpansion else { + return + } + let defaults = UserDefaults.standard + let groups = Array(vmGroups.values) + let encoder = JSONEncoder() + defaults.set(try? encoder.encode(groups), forKey: "VMGroups") + defaults.set(try? encoder.encode(sidebarItems), forKey: "VMSidebarItems") + } + + private func saveGroupExpansionState(id: UUID, isExpanded: Bool) { + let defaults = UserDefaults.standard + let key = groupExpansionStateDefaultsPrefix + id.uuidString + defaults.set(isExpanded, forKey: key) + } + /// Replace current VM list with a new list /// - Parameter vms: List to replace with fileprivate func listReplace(with vms: [VMData]) { virtualMachines.forEach({ endObservingChanges(for: $0) }) virtualMachines = vms vms.forEach({ beginObservingChanges(for: $0) }) + sidebarReconcileWithVMs() if let vm = selectedVM, !vms.contains(where: { $0 == vm }) { selectedVM = nil } @@ -299,7 +462,11 @@ enum AlertItem: Identifiable { } else { virtualMachines.append(vm) } + if !sidebarItems.contains(.vm(vm.id)) && groupContaining(vmID: vm.id) == nil { + sidebarItems.append(.vm(vm.id)) + } beginObservingChanges(for: vm) + syncVirtualMachineOrderFromSidebar() } /// Select VM in list @@ -317,6 +484,11 @@ enum AlertItem: Identifiable { if let index = index { virtualMachines.remove(at: index) } + sidebarItems.removeAll(where: { $0 == .vm(vm.id) }) + if let group = groupContaining(vmID: vm.id), var updatedGroup = vmGroups[group.id] { + updatedGroup.vmIDs.removeAll(where: { $0 == vm.id }) + vmGroups[group.id] = updatedGroup + } if vm == selectedVM { selectedVM = nil } @@ -354,6 +526,288 @@ enum AlertItem: Identifiable { virtualMachines.move(fromOffsets: fromOffsets, toOffset: toOffset) } + var sortedGroups: [VMGroup] { + vmGroups.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + + func vm(for id: UUID) -> VMData? { + virtualMachines.first(where: { $0.id == id }) + } + + func group(for id: UUID) -> VMGroup? { + vmGroups[id] + } + + func groupContaining(vmID: UUID) -> VMGroup? { + vmGroups.values.first(where: { $0.vmIDs.contains(vmID) }) + } + + func virtualMachines(inGroup groupID: UUID) -> [VMData] { + guard let group = vmGroups[groupID] else { + return [] + } + let vmMap = Dictionary(uniqueKeysWithValues: virtualMachines.map { ($0.id, $0) }) + return group.vmIDs.compactMap { vmID in + vmMap[vmID] + } + } + + func createGroup(named rawName: String, assigning vmID: UUID? = nil) { + guard let name = normalizedGroupName(rawName) else { + return + } + let groupID = UUID() + vmGroups[groupID] = VMGroup(id: groupID, title: name, vmIDs: [], isExpanded: true) + sidebarItems.append(.group(groupID)) + if let vmID { + addVM(vmID: vmID, toGroupID: groupID) + } + } + + func renameGroup(id: UUID, to rawName: String) { + guard let name = normalizedGroupName(rawName), var group = vmGroups[id] else { + return + } + group.title = name + vmGroups[id] = group + } + + func deleteGroup(id: UUID) { + guard let group = vmGroups[id] else { + return + } + let groupIndex = sidebarItems.firstIndex(of: .group(id)) + if let groupIndex { + sidebarItems.remove(at: groupIndex) + var insertIndex = groupIndex + for vmID in group.vmIDs { + let vmItem = VMSidebarItem.vm(vmID) + if !sidebarItems.contains(vmItem) { + sidebarItems.insert(vmItem, at: insertIndex) + insertIndex += 1 + } + } + } + vmGroups.removeValue(forKey: id) + UserDefaults.standard.removeObject(forKey: groupExpansionStateDefaultsPrefix + id.uuidString) + syncVirtualMachineOrderFromSidebar() + } + + func toggleGroupExpanded(id: UUID) { + guard let group = vmGroups[id] else { + return + } + setGroupExpanded(id: id, isExpanded: !group.isExpanded) + } + + func collapseGroup(id: UUID) { + guard let group = vmGroups[id], group.isExpanded else { + return + } + setGroupExpanded(id: id, isExpanded: false) + } + + func expandGroup(id: UUID) { + guard let group = vmGroups[id], !group.isExpanded else { + return + } + setGroupExpanded(id: id, isExpanded: true) + } + + func addVM(vmID: UUID, toGroupID groupID: UUID) { + guard var group = vmGroups[groupID] else { + return + } + removeVMFromGroup(vmID) + sidebarItems.removeAll(where: { $0 == .vm(vmID) }) + if !group.vmIDs.contains(vmID) { + group.vmIDs.append(vmID) + } + vmGroups[groupID] = group + syncVirtualMachineOrderFromSidebar() + } + + func removeVMFromGroup(_ vmID: UUID) { + guard let existingGroup = groupContaining(vmID: vmID), + var group = vmGroups[existingGroup.id] else { + return + } + group.vmIDs.removeAll(where: { $0 == vmID }) + vmGroups[group.id] = group + if !sidebarItems.contains(.vm(vmID)) { + if let groupIndex = sidebarItems.firstIndex(of: .group(group.id)) { + sidebarItems.insert(.vm(vmID), at: groupIndex + 1) + } else { + sidebarItems.append(.vm(vmID)) + } + } + syncVirtualMachineOrderFromSidebar() + } + + func moveSidebarItems(fromOffsets: IndexSet, toOffset: Int) { + sidebarItems.move(fromOffsets: fromOffsets, toOffset: toOffset) + syncVirtualMachineOrderFromSidebar() + } + + func moveVMs(inGroup groupID: UUID, fromOffsets: IndexSet, toOffset: Int) { + guard var group = vmGroups[groupID] else { + return + } + group.vmIDs.move(fromOffsets: fromOffsets, toOffset: toOffset) + vmGroups[groupID] = group + syncVirtualMachineOrderFromSidebar() + } + + func requestCreateGroup(assigning vmID: UUID? = nil) { + editingGroupID = nil + groupEditorTitle = "" + pendingGroupAssignmentVMID = vmID + showGroupEditorSheet = true + } + + func requestRenameGroup(id: UUID) { + guard let group = vmGroups[id] else { + return + } + editingGroupID = id + groupEditorTitle = group.title + pendingGroupAssignmentVMID = nil + showGroupEditorSheet = true + } + + func commitGroupEditorChanges() { + if let editingGroupID { + renameGroup(id: editingGroupID, to: groupEditorTitle) + } else { + createGroup(named: groupEditorTitle, assigning: pendingGroupAssignmentVMID) + } + resetGroupEditorState() + } + + func cancelGroupEditorChanges() { + resetGroupEditorState() + } + + private func normalizedGroupName(_ rawName: String) -> String? { + let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func setGroupExpanded(id: UUID, isExpanded: Bool) { + guard var group = vmGroups[id] else { + return + } + isUpdatingGroupExpansion = true + defer { + isUpdatingGroupExpansion = false + } + group.isExpanded = isExpanded + vmGroups[id] = group + saveGroupExpansionState(id: id, isExpanded: isExpanded) + } + + private func resetGroupEditorState() { + showGroupEditorSheet = false + editingGroupID = nil + pendingGroupAssignmentVMID = nil + groupEditorTitle = "" + } + + private func sidebarReconcileWithVMs() { + let vmIDs = Set(virtualMachines.map(\.id)) + let groupIDs = Set(vmGroups.keys) + + // Remove stale VM references from groups. + for key in vmGroups.keys { + guard var group = vmGroups[key] else { + continue + } + let filtered = group.vmIDs.filter { vmIDs.contains($0) } + if filtered != group.vmIDs { + group.vmIDs = filtered + vmGroups[key] = group + } + } + + // Remove stale top-level items. + sidebarItems.removeAll { item in + switch item { + case .vm(let id): + return !vmIDs.contains(id) + case .group(let id): + return !groupIDs.contains(id) + } + } + + // Ensure every VM appears exactly once in the sidebar model. + let groupedVMIDs = Set(vmGroups.values.flatMap(\.vmIDs)) + for vm in virtualMachines where !groupedVMIDs.contains(vm.id) { + let vmItem = VMSidebarItem.vm(vm.id) + if !sidebarItems.contains(vmItem) { + sidebarItems.append(vmItem) + } + } + + syncVirtualMachineOrderFromSidebar() + } + + private func syncVirtualMachineOrderFromSidebar() { + let flattenIDs = sidebarItems.flatMap { item -> [UUID] in + switch item { + case .vm(let id): + return [id] + case .group(let id): + return vmGroups[id]?.vmIDs ?? [] + } + } + let remainingIDs = virtualMachines.map(\.id).filter { !flattenIDs.contains($0) } + let orderedIDs = flattenIDs + remainingIDs + let vmMap = Dictionary(uniqueKeysWithValues: virtualMachines.map { ($0.id, $0) }) + let orderedVMs = orderedIDs.compactMap { vmMap[$0] } + if orderedVMs.count == virtualMachines.count { + virtualMachines = orderedVMs + } + } + + private func syncSelectedVMFromSidebarSelection() { + guard !isSyncingSidebarSelection else { + return + } + isSyncingSidebarSelection = true + defer { + isSyncingSidebarSelection = false + } + switch selectedSidebarItem { + case .vm(let vmID): + if selectedVM?.id != vmID { + selectedVM = vm(for: vmID) + } + case .group: + selectedVM = nil + case .none: + if selectedVM != nil { + selectedVM = nil + } + } + } + + private func syncSidebarSelectionFromSelectedVM() { + guard !isSyncingSidebarSelection else { + return + } + isSyncingSidebarSelection = true + defer { + isSyncingSidebarSelection = false + } + if let vm = selectedVM { + selectedSidebarItem = .vm(vm.id) + } else if case .group = selectedSidebarItem { + // Keep explicit group selection when details pane has no selected VM. + } else { + selectedSidebarItem = nil + } + } + // MARK: - New name /// Generate a unique VM name