// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindDomain /// Device-local presentation state for the Hosts and Snippets libraries. /// /// The Vault still carries the legacy `isExpanded` fields for wire and schema /// compatibility, but those values are imported only once. Every later change /// belongs to this installation and never enters a Vault transaction. struct LibraryExpansionState { private struct Snapshot: Codable, Equatable { var collapsedHostGroupIDs: Set = [] var collapsedSnippetPackageIDs: Set = [] var hostsUngroupedExpanded = true var snippetsUngroupedExpanded = true } static let storageKey = "library.expansionState.v1" static let migrationKey = "library.expansionState.migrated.v1" private let defaults: UserDefaults? private var snapshot: Snapshot init( defaults: UserDefaults? = nil, legacyGroups: [HostGroup] = [], legacySnippetPackages: [SnippetPackage] = [] ) { self.defaults = defaults if let defaults, defaults.bool(forKey: Self.migrationKey) { if let data = defaults.data(forKey: Self.storageKey), let decoded = try? JSONDecoder().decode(Snapshot.self, from: data) { snapshot = decoded } else { snapshot = Snapshot() persist() } return } snapshot = Snapshot( collapsedHostGroupIDs: Set(legacyGroups.filter { !$0.isExpanded }.map(\.id)), collapsedSnippetPackageIDs: Set( legacySnippetPackages.filter { !$0.isExpanded }.map(\.id) ) ) persist() defaults?.set(true, forKey: Self.migrationKey) } func isHostGroupExpanded(_ id: HostGroup.ID) -> Bool { !snapshot.collapsedHostGroupIDs.contains(id) } func isSnippetPackageExpanded(_ id: SnippetPackage.ID) -> Bool { !snapshot.collapsedSnippetPackageIDs.contains(id) } var isHostsUngroupedExpanded: Bool { snapshot.hostsUngroupedExpanded } var isSnippetsUngroupedExpanded: Bool { snapshot.snippetsUngroupedExpanded } mutating func setHostGroupExpanded(_ expanded: Bool, for id: HostGroup.ID) { guard Self.set(expanded, for: id, in: &snapshot.collapsedHostGroupIDs) else { return } persist() } mutating func setSnippetPackageExpanded(_ expanded: Bool, for id: SnippetPackage.ID) { guard Self.set(expanded, for: id, in: &snapshot.collapsedSnippetPackageIDs) else { return } persist() } mutating func setSnippetPackagesExpanded(_ ids: Set) { let previous = snapshot.collapsedSnippetPackageIDs snapshot.collapsedSnippetPackageIDs.subtract(ids) guard snapshot.collapsedSnippetPackageIDs != previous else { return } persist() } mutating func setHostsUngroupedExpanded(_ expanded: Bool) { guard snapshot.hostsUngroupedExpanded != expanded else { return } snapshot.hostsUngroupedExpanded = expanded persist() } mutating func setSnippetsUngroupedExpanded(_ expanded: Bool) { guard snapshot.snippetsUngroupedExpanded != expanded else { return } snapshot.snippetsUngroupedExpanded = expanded persist() } mutating func reconcile( hostGroupIDs: Set, snippetPackageIDs: Set ) { let previous = snapshot snapshot.collapsedHostGroupIDs.formIntersection(hostGroupIDs) snapshot.collapsedSnippetPackageIDs.formIntersection(snippetPackageIDs) guard snapshot != previous else { return } persist() } private static func set( _ expanded: Bool, for id: ID, in collapsed: inout Set ) -> Bool { expanded ? collapsed.remove(id) != nil : collapsed.insert(id).inserted } private func persist() { guard let defaults, let data = try? JSONEncoder().encode(snapshot) else { return } defaults.set(data, forKey: Self.storageKey) } }