From 44cc620d3dcf0c60cd629f7a4836cdae2c0fa684 Mon Sep 17 00:00:00 2001 From: tracer Date: Tue, 21 Apr 2026 00:15:08 +0200 Subject: [PATCH] feat: add optional server groups --- CHANGELOG.md | 22 +- Sources/Model/API/Server.swift | 11 +- Sources/Model/API/ServerGroup.swift | 11 + Sources/Views/GroupFormView.swift | 80 ++++++ Sources/Views/MainView.swift | 431 ++++++++++++++++++++++++---- Sources/Views/ServerFormView.swift | 48 ++-- 6 files changed, 519 insertions(+), 84 deletions(-) create mode 100644 Sources/Model/API/ServerGroup.swift create mode 100644 Sources/Views/GroupFormView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eea958..0648729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,20 @@ # Changelog -## 26.1.9 +## Unreleased (2026-04-21) +- Added optional sidebar groups for hosts, including group creation, editing, deletion, and host assignment. +- Added grouped host ordering, group reordering via drag and drop, and clearer visual feedback while moving groups. +- Improved group header styling to better distinguish groups and ungrouped hosts in the sidebar. +- Fixed a launch crash in grouped builds caused by async ping tasks writing back to stale array indexes after the server list changed. + +## 26.1.9 (2026-04-19) - Reduced idle CPU usage and energy impact by changing the interval indicator from a permanent 60 FPS timer to a 1-second update cadence. - Reset the interval indicator cleanly when the refresh interval changes or when the indicator is hidden. -## 26.1.8 +## 26.1.8 (2026-04-19) - Fixed a crash in `PingService` caused by concurrent mutation of shared ping state from multiple async ping tasks. - Moved ping state tracking and reboot suppression windows into an actor so ping success/failure handling is serialized safely. -## 26.1.7 +## 26.1.7 (2026-04-19) - Added remote reboot support for hosts running KeyHelp API 2.14 or newer. - Added a dedicated `APIv2_14` client and mapped 2.14+ hosts to it instead of treating them as API 2.13. - Fixed the reboot request to call `/api/v2/server/reboot` with the required JSON confirmation payload. @@ -16,22 +22,22 @@ - Improved API error messages by surfacing the server response body instead of only generic HTTP status codes. - Reduced expected reboot noise by suppressing ping checks for a short grace period after a reboot request. -## 26.1.6 +## 26.1.6 (2026-04-19) - Publish Gitea releases as stable by default instead of pre-releases. - Update the Homebrew tap automatically after each successful release by rewriting the cask version and DMG checksum, then pushing the tap repo. - Simplified the README for end users by adding clear install options and trimming internal release-engineering details. - Ignore the local `homebrew-tap/` checkout in the main app repository. -## 26.1.3 +## 26.1.3 (2026-01-03) - Fixed version handling for changelogs. -## 26.1.2 (2025-01-03) +## 26.1.2 (2026-01-03) - Synced version.json to 26.1.2. -## 26.1.1 (2025-01-03) +## 26.1.1 (2026-01-03) - Fixed changelog extraction in publish script. -## 26.1.0 (2025-01-03) +## 26.1.0 (2026-01-03) - Auto-populate release description from CHANGELOG when publishing to Gitea. ## Prereleases diff --git a/Sources/Model/API/Server.swift b/Sources/Model/API/Server.swift index 259240b..cdca407 100644 --- a/Sources/Model/API/Server.swift +++ b/Sources/Model/API/Server.swift @@ -10,12 +10,14 @@ import Foundation struct Server: Identifiable, Codable, Hashable, Equatable { let id: UUID var hostname: String + var groupID: UUID? var info: ServerInfo? var pingable: Bool - init(id: UUID = UUID(), hostname: String, info: ServerInfo? = nil, pingable: Bool = false) { + init(id: UUID = UUID(), hostname: String, groupID: UUID? = nil, info: ServerInfo? = nil, pingable: Bool = false) { self.id = id self.hostname = hostname + self.groupID = groupID self.info = info self.pingable = pingable } @@ -23,24 +25,26 @@ struct Server: Identifiable, Codable, Hashable, Equatable { // MARK: - Manual conformance static func == (lhs: Server, rhs: Server) -> Bool { - lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.info == rhs.info && lhs.pingable == rhs.pingable + lhs.id == rhs.id && lhs.hostname == rhs.hostname && lhs.groupID == rhs.groupID && lhs.info == rhs.info && lhs.pingable == rhs.pingable } func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(hostname) + hasher.combine(groupID) hasher.combine(info) hasher.combine(pingable) } enum CodingKeys: String, CodingKey { - case id, hostname, info, pingable + case id, hostname, groupID, info, pingable } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) hostname = try container.decode(String.self, forKey: .hostname) + groupID = try container.decodeIfPresent(UUID.self, forKey: .groupID) info = try container.decodeIfPresent(ServerInfo.self, forKey: .info) pingable = try container.decodeIfPresent(Bool.self, forKey: .pingable) ?? false } @@ -49,6 +53,7 @@ struct Server: Identifiable, Codable, Hashable, Equatable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(hostname, forKey: .hostname) + try container.encodeIfPresent(groupID, forKey: .groupID) try container.encodeIfPresent(info, forKey: .info) try container.encode(pingable, forKey: .pingable) } diff --git a/Sources/Model/API/ServerGroup.swift b/Sources/Model/API/ServerGroup.swift new file mode 100644 index 0000000..84a8907 --- /dev/null +++ b/Sources/Model/API/ServerGroup.swift @@ -0,0 +1,11 @@ +import Foundation + +struct ServerGroup: Identifiable, Codable, Hashable, Equatable { + let id: UUID + var name: String + + init(id: UUID = UUID(), name: String) { + self.id = id + self.name = name + } +} diff --git a/Sources/Views/GroupFormView.swift b/Sources/Views/GroupFormView.swift new file mode 100644 index 0000000..ac38a38 --- /dev/null +++ b/Sources/Views/GroupFormView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct GroupFormView: View { + enum Mode { + case add + case edit(ServerGroup) + } + + let mode: Mode + @Binding var groups: [ServerGroup] + let onSave: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(mode: Mode, groups: Binding<[ServerGroup]>, onSave: @escaping () -> Void) { + self.mode = mode + self._groups = groups + self.onSave = onSave + + switch mode { + case .add: + self._name = State(initialValue: "") + case .edit(let group): + self._name = State(initialValue: group.name) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(title) + .font(.headline) + + TextField("Group name", text: $name) + .textFieldStyle(.roundedBorder) + + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Save") { + save() + } + .disabled(trimmedName.isEmpty) + } + } + .padding() + .frame(width: 320) + } + + private var title: String { + switch mode { + case .add: + return "Add Group" + case .edit: + return "Edit Group" + } + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func save() { + switch mode { + case .add: + groups.append(ServerGroup(name: trimmedName)) + case .edit(let group): + if let index = groups.firstIndex(where: { $0.id == group.id }) { + groups[index].name = trimmedName + } + } + + onSave() + dismiss() + } +} diff --git a/Sources/Views/MainView.swift b/Sources/Views/MainView.swift index d800d4b..f113c0f 100644 --- a/Sources/Views/MainView.swift +++ b/Sources/Views/MainView.swift @@ -8,16 +8,23 @@ import SwiftUI import Combine import UserNotifications +import AppKit +import UniformTypeIdentifiers struct MainView: View { private static let serverOrderKeyStatic = "serverOrder" private static let storedServersKeyStatic = "storedServers" + private static let storedGroupsKeyStatic = "storedGroups" @State var showAddServerSheet: Bool = false + @State private var showAddGroupSheet: Bool = false @State private var serverBeingEdited: Server? + @State private var groupBeingEdited: ServerGroup? @State private var serverToDelete: Server? + @State private var groupToDelete: ServerGroup? @State private var showDeleteConfirmation = false + @State private var showDeleteGroupConfirmation = false @State private var isFetchingInfo: Bool = false @AppStorage("pingInterval") private var pingInterval: Int = 10 @AppStorage("refreshInterval") private var refreshInterval: Int = 60 @@ -27,12 +34,15 @@ struct MainView: View { @State private var refreshSubscription: AnyCancellable? @State private var pingTimer: Timer? @State private var restartingServerID: UUID? + @State private var draggedGroupID: UUID? + @State private var groupDropIndicator: GroupDropIndicator? @State private var lastRefreshInterval: Int? @State private var previousServiceStates: [String: String] = [:] private let serverOrderKey = MainView.serverOrderKeyStatic - private let storedServersKey = MainView.storedServersKeyStatic + private let storedGroupsKey = MainView.storedGroupsKeyStatic @State private var servers: [Server] = MainView.loadStoredServers() + @State private var groups: [ServerGroup] = MainView.loadStoredGroups() // @State private var selectedServer: Server? @State private var selectedServerID: UUID? @@ -40,81 +50,106 @@ struct MainView: View { var body: some View { var mainContent: some View { NavigationSplitView { - List(selection: $selectedServerID) { - ForEach(servers) { server in - HStack { - Image(systemName: "dot.circle.fill") - .foregroundColor(server.pingable ? .green : .red) - Text(server.hostname) + ZStack { + SidebarMaterialView() + + List(selection: $selectedServerID) { + sidebarContent } - .tag(server) - .contextMenu { - Button("Edit") { - print("Editing:", server.hostname) - serverBeingEdited = server - } - Divider() - Button("Delete", role: .destructive) { - serverToDelete = server - showDeleteConfirmation = true + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .background(Color.clear) + } + .background( + RoundedRectangle(cornerRadius: 0, style: .continuous) + .fill(.ultraThinMaterial) + ) + .overlay(alignment: .trailing) { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(width: 1) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button("Add Host") { + showAddServerSheet = true + } + Button("Add Group") { + showAddGroupSheet = true + } + } label: { + Image(systemName: "plus") } + .help("Add Host or Group") } } - .onMove(perform: moveServer) - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: { showAddServerSheet = true }) { - Image(systemName: "plus") + .navigationTitle("Servers") + .onChange(of: selectedServerID) { + if let selectedServerID { + UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID") + fetchServerInfo(for: selectedServerID) } - .help("Add Host") } - } - .navigationTitle("Servers") - .onChange(of: selectedServerID) { - if let selectedServerID { - UserDefaults.standard.set(selectedServerID.uuidString, forKey: "selectedServerID") - fetchServerInfo(for: selectedServerID) + } detail: { + if let selectedServerID, + let index = servers.firstIndex(where: { selectedServerID == $0.id }) { + let serverID = servers[index].id + ServerDetailView( + server: $servers[index], + isFetching: isFetchingInfo, + canRestart: servers[index].info?.supportsRestartCommand == true, + isRestarting: restartingServerID == serverID + ) { + await restartServer(for: serverID) + } + } else { + ContentUnavailableView("No Server Selected", systemImage: "server.rack") } } - } detail: { - if let selectedServerID, - let index = servers.firstIndex(where: { selectedServerID == $0.id }) { - let serverID = servers[index].id - ServerDetailView( - server: $servers[index], - isFetching: isFetchingInfo, - canRestart: servers[index].info?.supportsRestartCommand == true, - isRestarting: restartingServerID == serverID - ) { - await restartServer(for: serverID) - } - } else { - ContentUnavailableView("No Server Selected", systemImage: "server.rack") - } - } } return mainContent .sheet(isPresented: $showAddServerSheet) { ServerFormView( mode: .add, servers: $servers, + groups: $groups, dismiss: { showAddServerSheet = false } ) } + .sheet(isPresented: $showAddGroupSheet) { + GroupFormView(mode: .add, groups: $groups) { + saveGroups() + } + } .sheet(item: $serverBeingEdited) { server in ServerFormView( mode: .edit(server), servers: $servers, + groups: $groups, dismiss: { serverBeingEdited = nil } ) } + .sheet(item: $groupBeingEdited) { group in + GroupFormView(mode: .edit(group), groups: $groups) { + saveGroups() + } + } .alert("Are you sure you want to delete this server?", isPresented: $showDeleteConfirmation, presenting: serverToDelete) { server in Button("Delete", role: .destructive) { ServerFormView.delete(server: server, from: &servers) + saveServers() } Button("Cancel", role: .cancel) {} } + .alert("Are you sure you want to delete this group?", isPresented: $showDeleteGroupConfirmation, presenting: groupToDelete) { group in + Button("Delete", role: .destructive) { + deleteGroup(group) + } + Button("Cancel", role: .cancel) {} + } message: { group in + Text("Servers in \(group.name) will remain available and become ungrouped.") + } .onAppear { requestNotificationPermissions() @@ -149,8 +184,145 @@ struct MainView: View { setupRefreshTimer() } } + .onChange(of: groups) { _, _ in + saveGroups() + } .frame(minWidth: 800, minHeight: 450) } + + @ViewBuilder + private var sidebarContent: some View { + if groups.isEmpty { + ForEach(servers) { server in + sidebarRow(for: server) + } + .onMove(perform: moveServer) + } else { + ForEach(groups) { group in + Section { + ForEach(servers(in: group)) { server in + sidebarRow(for: server) + } + .onMove { source, destination in + moveServers(in: group.id, from: source, to: destination) + } + } header: { + groupHeader(for: group) + } + } + + if !ungroupedServers.isEmpty { + Section { + ForEach(ungroupedServers) { server in + sidebarRow(for: server) + } + .onMove { source, destination in + moveServers(in: nil, from: source, to: destination) + } + } header: { + sidebarSectionHeader("Ungrouped") + } + } + } + } + + private func sidebarSectionHeader(_ title: String) -> some View { + HStack { + Text(title) + .font(.system(size: NSFont.systemFontSize + 1, weight: .bold)) + .foregroundStyle(Color.accentColor) + Spacer(minLength: 0) + } + .padding(.vertical, 4) + } + + private func sidebarRow(for server: Server) -> some View { + HStack { + Image(systemName: "dot.circle.fill") + .foregroundColor(server.pingable ? .green : .red) + Text(server.hostname) + } + .tag(server.id) + .contextMenu { + Button("Edit") { + print("Editing:", server.hostname) + serverBeingEdited = server + } + Divider() + Button("Delete", role: .destructive) { + serverToDelete = server + showDeleteConfirmation = true + } + } + } + + private func groupHeader(for group: ServerGroup) -> some View { + let activePlacement = groupDropIndicator?.groupID == group.id ? groupDropIndicator?.placement : nil + + return VStack(spacing: 0) { + if activePlacement == .before { + dropIndicator + } + + sidebarSectionHeader(group.name) + .contentShape(Rectangle()) + .background { + if activePlacement != nil { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + } + } + + if activePlacement == .after { + dropIndicator + } + } + .onDrag { + draggedGroupID = group.id + return NSItemProvider(object: group.id.uuidString as NSString) + } + .onDrop( + of: [UTType.text], + delegate: GroupDropDelegate( + targetGroup: group, + groups: $groups, + draggedGroupID: $draggedGroupID, + indicator: $groupDropIndicator + ) + ) + .contextMenu { + Button("Edit Group") { + groupBeingEdited = group + } + Button("Delete Group", role: .destructive) { + groupToDelete = group + showDeleteGroupConfirmation = true + } + } + } + + private var dropIndicator: some View { + VStack(spacing: 4) { + Capsule() + .fill(Color.accentColor) + .frame(height: 3) + .shadow(color: Color.accentColor.opacity(0.25), radius: 1, y: 0) + Color.clear + .frame(height: 4) + } + .padding(.vertical, 2) + } + + private var ungroupedServers: [Server] { + servers.filter { server in + guard let groupID = server.groupID else { return true } + return groups.contains(where: { $0.id == groupID }) == false + } + } + + private func servers(in group: ServerGroup) -> [Server] { + servers.filter { $0.groupID == group.id } + } private func fetchServerInfo(for id: UUID) { guard let server = servers.first(where: { $0.id == id }) else { @@ -223,6 +395,41 @@ struct MainView: View { private func moveServer(from source: IndexSet, to destination: Int) { servers.move(fromOffsets: source, toOffset: destination) + saveServers() + saveServerOrder() + } + + private func moveServers(in groupID: UUID?, from source: IndexSet, to destination: Int) { + let matchingServers = servers.filter { server in + if let groupID { + return server.groupID == groupID + } + return server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false + } + + var reorderedServers = matchingServers + reorderedServers.move(fromOffsets: source, toOffset: destination) + + let replacements = Dictionary(uniqueKeysWithValues: reorderedServers.map { ($0.id, $0) }) + var reorderedIDs = reorderedServers.map(\.id) + + servers = servers.map { server in + let belongsInSection: Bool + if let groupID { + belongsInSection = server.groupID == groupID + } else { + belongsInSection = server.groupID == nil || groups.contains(where: { $0.id == server.groupID }) == false + } + + guard belongsInSection, let nextID = reorderedIDs.first else { + return server + } + + reorderedIDs.removeFirst() + return replacements[nextID] ?? server + } + + saveServers() saveServerOrder() } @@ -231,17 +438,45 @@ struct MainView: View { UserDefaults.standard.set(ids, forKey: serverOrderKey) print("💾 [MainView] Saved server order with \(ids.count) entries") } + + private func saveServers() { + if let data = try? JSONEncoder().encode(servers) { + UserDefaults.standard.set(data, forKey: MainView.storedServersKeyStatic) + } + } + + private func saveGroups() { + if let data = try? JSONEncoder().encode(groups) { + UserDefaults.standard.set(data, forKey: storedGroupsKey) + } + } + + private func deleteGroup(_ group: ServerGroup) { + groups.removeAll { $0.id == group.id } + for index in servers.indices { + if servers[index].groupID == group.id { + servers[index].groupID = nil + } + } + saveGroups() + saveServers() + } private struct PingResponse: Codable { let response: String } func pingAllServers() { - for (index, server) in servers.enumerated() { + let pingTargets = servers.map { ($0.id, $0.hostname) } + + for (serverID, hostname) in pingTargets { Task { - let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let pingable = await PingService.ping(hostname: server.hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications) + let apiKey = KeychainHelper.loadApiKey(for: hostname)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let pingable = await PingService.ping(hostname: hostname, apiKey: apiKey, notificationsEnabled: enableStatusNotifications) await MainActor.run { + guard let index = servers.firstIndex(where: { $0.id == serverID }) else { + return + } servers[index].pingable = pingable } } @@ -299,6 +534,20 @@ struct MainView: View { } } + private static func loadStoredGroups() -> [ServerGroup] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: storedGroupsKeyStatic) else { + return [] + } + + do { + return try JSONDecoder().decode([ServerGroup].self, from: data) + } catch { + print("❌ [MainView] Failed to decode stored groups: \(error)") + return [] + } + } + private func requestNotificationPermissions() { Task { do { @@ -421,6 +670,90 @@ struct MainView: View { } } +private struct SidebarMaterialView: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .behindWindow + view.material = .sidebar + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.state = .active + } +} + +private struct GroupDropDelegate: DropDelegate { + enum Placement { + case before + case after + } + + let targetGroup: ServerGroup + @Binding var groups: [ServerGroup] + @Binding var draggedGroupID: UUID? + @Binding var indicator: GroupDropIndicator? + + func dropEntered(info: DropInfo) { + updateIndicator(with: info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + updateIndicator(with: info) + return DropProposal(operation: .move) + } + + func dropExited(info: DropInfo) { + indicator = nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedGroupID = nil + indicator = nil + } + + guard + let draggedGroupID, + draggedGroupID != targetGroup.id, + let fromIndex = groups.firstIndex(where: { $0.id == draggedGroupID }), + let toIndex = groups.firstIndex(where: { $0.id == targetGroup.id }) + else { + return false + } + + let placement = placement(for: info) + let proposedIndex = placement == .after ? toIndex + 1 : toIndex + groups.move( + fromOffsets: IndexSet(integer: fromIndex), + toOffset: proposedIndex > fromIndex ? proposedIndex + 1 : proposedIndex + ) + return true + } + + private func updateIndicator(with info: DropInfo) { + guard let draggedGroupID, draggedGroupID != targetGroup.id else { + indicator = nil + return + } + + indicator = GroupDropIndicator( + groupID: targetGroup.id, + placement: placement(for: info) + ) + } + + private func placement(for info: DropInfo) -> Placement { + info.location.y > 12 ? .after : .before + } +} + +private struct GroupDropIndicator: Equatable { + let groupID: UUID + let placement: GroupDropDelegate.Placement +} + #Preview { MainView() .environmentObject(SparkleUpdater()) diff --git a/Sources/Views/ServerFormView.swift b/Sources/Views/ServerFormView.swift index 400d77a..d446cf8 100644 --- a/Sources/Views/ServerFormView.swift +++ b/Sources/Views/ServerFormView.swift @@ -16,9 +16,11 @@ struct ServerFormView: View { var mode: Mode @Binding var servers: [Server] + @Binding var groups: [ServerGroup] @State private var hostname: String @State private var apiKey: String + @State private var selectedGroupID: UUID? @State private var connectionOK: Bool = false @State private var testingConnection: Bool = false @State private var connectionError: String = "" @@ -29,25 +31,30 @@ struct ServerFormView: View { init( mode: Mode, servers: Binding<[Server]>, + groups: Binding<[ServerGroup]>, dismiss: @escaping () -> Void ) { self.mode = mode self._servers = servers + self._groups = groups switch mode { case .add: self._hostname = State(initialValue: "") self._apiKey = State(initialValue: "") + self._selectedGroupID = State(initialValue: nil) case .edit(let server): self._hostname = State(initialValue: server.hostname) self._apiKey = State(initialValue: KeychainHelper.loadApiKey(for: server.hostname) ?? "") + self._selectedGroupID = State(initialValue: server.groupID) + self._connectionOK = State(initialValue: true) } } var body: some View { VStack { - Text("Edit Server") + Text(modeTitle) .font(.headline) TextField("Hostname", text: $hostname) @@ -57,6 +64,14 @@ struct ServerFormView: View { SecureField("API Key", text: $apiKey) .textFieldStyle(RoundedBorderTextFieldStyle()) + Picker("Group", selection: $selectedGroupID) { + Text("No Group").tag(nil as UUID?) + ForEach(groups) { group in + Text(group.name).tag(Optional(group.id)) + } + } + .pickerStyle(.menu) + if !connectionError.isEmpty { Text(connectionError) .foregroundColor(.red) @@ -77,8 +92,6 @@ struct ServerFormView: View { Button("Save") { saveServer() - updateServer() - saveServers() dismiss() } .disabled(hostname.isEmpty || apiKey.isEmpty || !connectionOK) @@ -93,6 +106,8 @@ struct ServerFormView: View { print("serve \(server)") hostname = server.hostname apiKey = KeychainHelper.loadApiKey(for: server.hostname) ?? "" + selectedGroupID = server.groupID + connectionOK = true print("💡 Loaded server: \(hostname)") } } @@ -205,40 +220,22 @@ struct ServerFormView: View { switch mode { case .add: print("adding server") - let newServer = Server(hostname: trimmedHost) + let newServer = Server(hostname: trimmedHost, groupID: selectedGroupID) servers.append(newServer) KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost) - saveServers() case .edit(let oldServer): if let index = servers.firstIndex(where: { $0.id == oldServer.id }) { let oldHostname = servers[index].hostname servers[index].hostname = trimmedHost + servers[index].groupID = selectedGroupID if oldHostname != trimmedHost { KeychainHelper.deleteApiKey(for: oldHostname) } KeychainHelper.saveApiKey(trimmedKey, for: trimmedHost) } } - } - - private func updateServer() { - print ("in edit server") - guard case let .edit(server) = mode else { - return - } - - if let index = servers.firstIndex(where: { $0.id == server.id }) { - // Only replace hostname if changed - let oldHostname = servers[index].hostname - servers[index].hostname = hostname - // Update Keychain - if oldHostname != hostname { - KeychainHelper.deleteApiKey(for: oldHostname) - } - KeychainHelper.saveApiKey(apiKey, for: hostname) - saveServers() - } + saveServers() } private func saveServers() { @@ -265,6 +262,9 @@ struct ServerFormView: View { servers: .constant([ Server(hostname: "example.com") ]), + groups: .constant([ + ServerGroup(name: "Production") + ]), dismiss: {} ) }