diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 6ac509bb..30399278 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -28,6 +28,7 @@ enum PreferenceKey: String { case hideSupportXcodes case xcodeListArchitectures case enableGroupedXcodeList + case showTags case expandedMajorXcodeVersions case expandedMinorXcodeVersions case usePrivilegeHelperForFileOperations @@ -173,6 +174,12 @@ class AppState: ObservableObject { } } + @Published var showTags = true { + didSet { + Current.defaults.set(showTags, forKey: PreferenceKey.showTags.rawValue) + } + } + // MARK: - Runtimes @Published var downloadableRuntimes: [DownloadableRuntime] = [] @@ -272,6 +279,7 @@ class AppState: ObservableObject { terminateAfterLastWindowClosed = Current.defaults.bool(forKey: "terminateAfterLastWindowClosed") ?? false enableGroupedXcodeList = Current.defaults.get(forKey: PreferenceKey.enableGroupedXcodeList.rawValue) as? Bool ?? true usePrivilegedHelperForFileOperations = Current.defaults.bool(forKey: PreferenceKey.usePrivilegeHelperForFileOperations.rawValue) ?? false + showTags = Current.defaults.get(forKey: PreferenceKey.showTags.rawValue) as? Bool ?? true } // MARK: Timer diff --git a/Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift b/Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift index 92dafff3..a862c965 100644 --- a/Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift +++ b/Xcodes/Frontend/Common/NavigationSplitViewWrapper.swift @@ -23,7 +23,7 @@ struct NavigationSplitViewWrapper: View where Sidebar: View, De NavigationSplitView { if #available(macOS 14, *) { sidebar - .navigationSplitViewColumnWidth(min: 290, ideal: 290) + .navigationSplitViewColumnWidth(min: 380, ideal: 440, max: 700) } else { sidebar } diff --git a/Xcodes/Frontend/Common/TagView.swift b/Xcodes/Frontend/Common/TagView.swift index 9d97614e..12fcdc43 100644 --- a/Xcodes/Frontend/Common/TagView.swift +++ b/Xcodes/Frontend/Common/TagView.swift @@ -6,6 +6,8 @@ import SwiftUI +import Version +import XcodesKit struct TagView: View { let text: String @@ -22,3 +24,66 @@ struct TagView: View { ) } } + +/// A tinted capsule that classifies a release at a glance, e.g. Beta, RC or Latest. +struct ReleaseTagView: View { + enum Kind { + case beta + case releaseCandidate + case otherPrerelease + case latest + case active + + var color: Color { + switch self { + case .beta: return .orange + case .releaseCandidate: return .purple + case .otherPrerelease: return .teal + case .latest: return .green + case .active: return .blue + } + } + } + + let label: Text + let kind: Kind + + var body: some View { + label + .font(.caption2.weight(.semibold)) + .foregroundStyle(kind.color) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(kind.color.opacity(0.16), in: Capsule()) + .overlay(Capsule().strokeBorder(kind.color.opacity(0.35), lineWidth: 0.5)) + .fixedSize() + } +} + +extension ReleaseTagView { + /// A tag describing a version's prerelease, e.g. "Beta 6" or "RC 2", or nil for a release. + init?(prereleaseOf version: Version) { + guard version.isPrerelease else { return nil } + let base = Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription + let text = version.appleDescription + .dropFirst(base.count) + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: "Release Candidate", with: "RC") + self.init(label: Text(verbatim: text), kind: Self.kind(forPrerelease: version)) + } + + static func kind(forPrerelease version: Version) -> Kind { + let identifiers = version.prereleaseIdentifiers.joined(separator: " ").lowercased() + if identifiers.contains("beta") { return .beta } + if identifiers.contains("release") || identifiers.contains("rc") || identifiers.contains("gm") { return .releaseCandidate } + return .otherPrerelease + } + + static var active: ReleaseTagView { + ReleaseTagView(label: Text("Tag.Active"), kind: .active) + } + + static var latest: ReleaseTagView { + ReleaseTagView(label: Text("Tag.Latest"), kind: .latest) + } +} diff --git a/Xcodes/Frontend/InfoPane/InfoPane.swift b/Xcodes/Frontend/InfoPane/InfoPane.swift index b557e748..11ef5f5d 100644 --- a/Xcodes/Frontend/InfoPane/InfoPane.swift +++ b/Xcodes/Frontend/InfoPane/InfoPane.swift @@ -24,10 +24,17 @@ struct InfoPane: View { HStack { IconView(xcode: xcode) - Text(verbatim: "Xcode \(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.title) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "Xcode \(xcode.description)") + .font(.title) + if !xcode.version.buildMetadataIdentifiers.isEmpty { + Text(verbatim: xcode.version.buildMetadataIdentifiers.joined(separator: " ")) + .font(.subheadline.monospaced()) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) } InfoPaneControls(xcode: xcode) } diff --git a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift index 2ccfe346..9751e5cd 100644 --- a/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/GeneralPreferencePane.swift @@ -21,9 +21,15 @@ struct GeneralPreferencePane: View { .groupBoxStyle(PreferencesGroupBoxStyle()) Divider() + GroupBox(label: Text("UI")) { + Toggle("CollapseMajorMinorVersions", isOn: $appState.enableGroupedXcodeList) + Toggle("ShowTags", isOn: $appState.showTags) + } + .groupBoxStyle(PreferencesGroupBoxStyle()) + Divider() + GroupBox(label: Text("Misc")) { Toggle("TerminateAfterLastWindowClosed", isOn: $appState.terminateAfterLastWindowClosed) - Toggle("GroupXcodeVersionsInList", isOn: $appState.enableGroupedXcodeList) } .groupBoxStyle(PreferencesGroupBoxStyle()) } diff --git a/Xcodes/Frontend/Preferences/PreferencesView.swift b/Xcodes/Frontend/Preferences/PreferencesView.swift index 39509cf5..85205f4d 100644 --- a/Xcodes/Frontend/Preferences/PreferencesView.swift +++ b/Xcodes/Frontend/Preferences/PreferencesView.swift @@ -1,44 +1,84 @@ import SwiftUI struct PreferencesView: View { - private enum Tabs: Hashable { - case general, updates, advanced, experiment + private enum Tabs: Hashable, CaseIterable { + case general, updates, downloads, advanced, experiment + + var title: LocalizedStringKey { + switch self { + case .general: return "General" + case .updates: return "Updates" + case .downloads: return "Downloads" + case .advanced: return "Advanced" + case .experiment: return "Experiments" + } + } + + var symbol: String { + switch self { + case .general: return "gearshape" + case .updates: return "arrow.triangle.2.circlepath.circle" + case .downloads: return "icloud.and.arrow.down" + case .advanced: return "gearshape.2" + case .experiment: return "lightbulb" + } + } } + + @State private var selectedTab = Tabs.general @EnvironmentObject var appState: AppState @EnvironmentObject var updater: ObservableUpdater - + var body: some View { - TabView { - GeneralPreferencePane() - .environmentObject(appState) - .tabItem { - Label("General", systemImage: "gearshape") + VStack(spacing: 20) { + // Native Settings toolbar images can retain the first tab's accent tint. + HStack(spacing: 4) { + ForEach(Tabs.allCases, id: \.self) { tab in + Button { + selectedTab = tab + } label: { + VStack(spacing: 6) { + Image(systemName: tab.symbol) + .symbolRenderingMode(.monochrome) + .font(.system(size: 24)) + .frame(height: 28) + Text(tab.title) + } + .foregroundStyle(selectedTab == tab ? Color.accentColor : Color.secondary) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(selectedTab == tab ? Color.primary.opacity(0.08) : Color.clear, in: RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .focusEffectDisabled() + .accessibilityAddTraits(selectedTab == tab ? .isSelected : []) } - .tag(Tabs.general) - UpdatesPreferencePane() - .environmentObject(updater) - .tabItem { - Label("Updates", systemImage: "arrow.triangle.2.circlepath.circle") - } - .tag(Tabs.updates) - DownloadPreferencePane() - .environmentObject(appState) - .tabItem { - Label("Downloads", systemImage: "icloud.and.arrow.down") - } - AdvancedPreferencePane() - .environmentObject(appState) - .tabItem { - Label("Advanced", systemImage: "gearshape.2") - } - .tag(Tabs.advanced) - ExperimentsPreferencePane() - .tabItem { - Label("Experiments", systemImage: "lightbulb") + } + Divider() + + Group { + switch selectedTab { + case .general: + GeneralPreferencePane() + .environmentObject(appState) + case .updates: + UpdatesPreferencePane() + .environmentObject(updater) + case .downloads: + DownloadPreferencePane() + .environmentObject(appState) + case .advanced: + AdvancedPreferencePane() + .environmentObject(appState) + case .experiment: + ExperimentsPreferencePane() + .environmentObject(appState) } - .tag(Tabs.experiment) + } } .padding(20) .frame(width: 600) } + } diff --git a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift index de9ab1f1..eba9ee7c 100644 --- a/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift +++ b/Xcodes/Frontend/Preferences/UpdatesPreferencePane.swift @@ -110,9 +110,9 @@ class ObservableUpdater: ObservableObject { automaticallyChecksForUpdatesObservation = updater.observe( \.automaticallyChecksForUpdates, options: [.initial, .new, .old], - changeHandler: { [weak self] updater, change in - guard change.newValue != change.oldValue else { return } - let automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + changeHandler: { [weak self] _, change in + // Use the observed value: the updater's properties are main actor-isolated and this handler isn't + guard change.newValue != change.oldValue, let automaticallyChecksForUpdates = change.newValue else { return } Task { @MainActor [weak self] in self?.automaticallyChecksForUpdates = automaticallyChecksForUpdates } @@ -121,8 +121,8 @@ class ObservableUpdater: ObservableObject { lastUpdateCheckDateObservation = updater.observe( \.lastUpdateCheckDate, options: [.initial, .new, .old], - changeHandler: { [weak self] updater, change in - let lastUpdateCheckDate = updater.lastUpdateCheckDate + changeHandler: { [weak self] _, change in + let lastUpdateCheckDate = change.newValue ?? nil Task { @MainActor [weak self] in self?.lastUpdateCheckDate = lastUpdateCheckDate } diff --git a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift index 1d605fa0..7b03acf7 100644 --- a/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift +++ b/Xcodes/Frontend/XcodeList/InstallationStepRowView.swift @@ -1,40 +1,100 @@ import SwiftUI import XcodesKit +/// A compact, App Store style progress ring for a row: a determinate ring while downloading and a +/// spinning arc for the other steps, with a stop button in the middle. Step details are in the tooltip. struct InstallationStepRowView: View { let installationStep: XcodeInstallationStep let highlighted: Bool let cancel: () -> Void - + var body: some View { - HStack { + Group { switch installationStep { case let .downloading(progress): // FB8955769 ProgressView.init(_: Progress) doesn't ensure that changes from the Progress object are applied to the UI on the main thread - // This Progress is vended by URLSession so I don't think we can control that. - // Use our own version of ProgressView that does this instead. - ObservingProgressIndicator( - progress, - controlSize: .small, - style: .spinning - ) + DownloadProgressRing(progress: progress, highlighted: highlighted, stepDescription: stepDescription, cancel: cancel) case .authenticating, .unarchiving, .moving, .trashingArchive, .checkingSecurity, .finishing: - ProgressView() - .scaleEffect(0.5) + ProgressRing(fraction: nil, highlighted: highlighted, cancel: cancel) + .help(stepDescription) } - - Text(String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message)) - .font(.footnote) - - Button(action: cancel) { - Label("Cancel", systemImage: "xmark.circle.fill") - .labelStyle(IconOnlyLabelStyle()) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(stepDescription)) + } + + private var stepDescription: String { + String(format: localizeString("InstallationStepDescription"), installationStep.stepNumber, installationStep.stepCount, installationStep.message) + } +} + +private struct DownloadProgressRing: View { + @StateObject private var progress: ObservingProgressIndicator.ProgressWrapper + let highlighted: Bool + let stepDescription: String + let cancel: () -> Void + + init(progress: Progress, highlighted: Bool, stepDescription: String, cancel: @escaping () -> Void) { + _progress = StateObject(wrappedValue: ObservingProgressIndicator.ProgressWrapper(progress: progress)) + self.highlighted = highlighted + self.stepDescription = stepDescription + self.cancel = cancel + } + + var body: some View { + let progress = progress.progress + let percent = String(format: localizeString("DownloadingPercentDescription"), Int(progress.fractionCompleted * 100)) + let details = progress.xcodesLocalizedDescription + ProgressRing(fraction: progress.isIndeterminate ? nil : progress.fractionCompleted, highlighted: highlighted, cancel: cancel) + .help([stepDescription, percent, details].filter { !$0.isEmpty }.joined(separator: "\n")) + } +} + +private struct ProgressRing: View { + /// nil shows an indeterminate spinning arc + let fraction: Double? + let highlighted: Bool + let cancel: () -> Void + + @State private var isHovering = false + @State private var isSpinning = false + + private let size: CGFloat = 24 + private let lineWidth: CGFloat = 2.5 + + var body: some View { + Button(action: cancel) { + ZStack { + Circle() + .stroke(trackColor, lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: fraction.map { max(0.02, min(1, $0)) } ?? 0.25) + .stroke(ringColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(fraction == nil && isSpinning ? 270 : -90)) + .animation(fraction == nil ? .linear(duration: 1).repeatForever(autoreverses: false) : .easeOut(duration: 0.3), value: isSpinning) + .animation(.easeOut(duration: 0.3), value: fraction) + + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(isHovering ? ringColor : ringColor.opacity(0.8)) + .frame(width: 7, height: 7) } - .buttonStyle(PlainButtonStyle()) - .foregroundColor(highlighted ? .white : .secondary) - .help("StopInstallation") + .frame(width: size, height: size) + .contentShape(Circle()) } - .frame(minWidth: 80) + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .onAppear { isSpinning = fraction == nil } + .onChange(of: fraction == nil) { _, isIndeterminate in isSpinning = isIndeterminate } + .accessibilityLabel(Text("StopInstallation")) + } + + private var ringColor: Color { + highlighted ? .white : .accentColor + } + + private var trackColor: Color { + highlighted ? .white.opacity(0.35) : .secondary.opacity(0.25) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 12cfdb05..ea7d4009 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -11,6 +11,9 @@ struct XcodeListView: View { private let architecture: XcodeListArchitecture private let isInstalledOnly: Bool @AppStorage(PreferenceKey.allowedMajorVersions.rawValue) private var allowedMajorVersions = Int.max + @State private var expansion = XcodeListExpansion() + @State private var visibleXcodes: [XcodeListEntry] = [] + @State private var groupedSnapshot = XcodeGroupedListSnapshot(xcodes: [], allXcodes: []) init(selectedXcodeID: Binding, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool, architecture: XcodeListArchitecture) { self._selectedXcodeID = selectedXcodeID @@ -20,8 +23,9 @@ struct XcodeListView: View { self.architecture = architecture } - private var visibleXcodes: [XcodeListEntry] { - appState.allXcodes + private func updateListSnapshot(xcodes: [Xcode]? = nil) { + let allXcodes = xcodes ?? appState.allXcodes + let entries = allXcodes .enumerated() .map { XcodeListEntry(index: $0.offset, xcode: $0.element) } .applying(XcodeListFilters( @@ -31,18 +35,45 @@ struct XcodeListView: View { searchText: searchText, installedOnly: isInstalledOnly ), item: \.listItem) + let snapshot = XcodeGroupedListSnapshot(xcodes: entries, allXcodes: allXcodes) + withAnimation(visibleXcodes.isEmpty ? nil : .easeInOut(duration: 0.18)) { + visibleXcodes = entries + groupedSnapshot = snapshot + } } private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { appState.allXcodes.latestReleaseForSelectedPrerelease(xcode) } + /// Version rows in display order, for moving the selection with the arrow keys + private var selectableXcodeIDs: [Xcode.ID] { + guard appState.enableGroupedXcodeList else { return visibleXcodes.map(\.xcode.id) } + return groupedSnapshot.rows(expansion: expansion).compactMap { row in + if case let .version(entry, _, _, _) = row { return entry.xcode.id } + return nil + } + } + + private func moveSelection(by offset: Int) -> KeyPress.Result { + let ids = selectableXcodeIDs + guard !ids.isEmpty else { return .ignored } + guard let current = selectedXcodeID, let index = ids.firstIndex(of: current) else { + selectedXcodeID = offset > 0 ? ids.first : ids.last + return .handled + } + selectedXcodeID = ids[min(max(index + offset, 0), ids.count - 1)] + return .handled + } + var body: some View { - List(selection: $selectedXcodeID) { + // Selection is drawn by the rows (SelectableRow) rather than List(selection:), because the sidebar's + // system highlight can't be restyled and left tags and secondary text hard to read. + List { if appState.enableGroupedXcodeList { GroupedXcodeListContent( - xcodes: visibleXcodes, - allXcodes: appState.allXcodes, + snapshot: groupedSnapshot, + expansion: $expansion, selectedXcodeID: $selectedXcodeID, appState: appState ) @@ -52,13 +83,29 @@ struct XcodeListView: View { xcode: entry.xcode, selected: selectedXcodeID == entry.xcode.id, appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) + latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode), + isLatestRelease: entry.xcode.version.isNotPrerelease && groupedSnapshot.latestStableVersion.map { entry.xcode.version.isEquivalent(to: $0) } == true ) - .tag(entry.xcode.id) + .selectableRow(isSelected: selectedXcodeID == entry.xcode.id) { selectedXcodeID = entry.xcode.id } } } } + .onReceive(appState.$allXcodes) { updateListSnapshot(xcodes: $0) } + .onChange(of: searchText) { updateListSnapshot() } + .onChange(of: category) { updateListSnapshot() } + .onChange(of: architecture) { updateListSnapshot() } + .onChange(of: isInstalledOnly) { updateListSnapshot() } + .onChange(of: allowedMajorVersions) { updateListSnapshot() } + .task(id: expansion) { + // Persist after the UI has expanded, coalescing rapid clicks. + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + expansion.save() + } .listStyle(.sidebar) + .focusable() + .focusEffectDisabled() + .onKeyPress(.downArrow) { moveSelection(by: 1) } + .onKeyPress(.upArrow) { moveSelection(by: -1) } .safeAreaInset(edge: .bottom, spacing: 0) { PlatformsPocket() .padding(.horizontal) @@ -68,7 +115,7 @@ struct XcodeListView: View { } } -private struct XcodeListEntry: Identifiable { +struct XcodeListEntry: Identifiable { let index: Int let xcode: Xcode @@ -81,123 +128,277 @@ private struct XcodeListEntry: Identifiable { } } -private struct GroupedXcodeListContent: View { - let xcodes: [XcodeListEntry] - let allXcodes: [Xcode] - @Binding var selectedXcodeID: Xcode.ID? - let appState: AppState +/// Expansion is local UI state; saving it never delays a disclosure click. +struct XcodeListExpansion: Equatable, Hashable { + var majorVersions: Set + var minorVersions: Set - @AppStorage(PreferenceKey.expandedMajorXcodeVersions.rawValue) private var expandedMajorVersionStorage = "" - @AppStorage(PreferenceKey.expandedMinorXcodeVersions.rawValue) private var expandedMinorVersionStorage = "" + init(majorVersions: Set, minorVersions: Set) { + self.majorVersions = majorVersions + self.minorVersions = minorVersions + } - private var expandedMajorVersions: Set { - get { - Set(expandedMajorVersionStorage.split(separator: ",").compactMap { Int($0) }) - } - nonmutating set { - expandedMajorVersionStorage = newValue.sorted().map(String.init).joined(separator: ",") - } + init() { + let defaults = UserDefaults.standard + majorVersions = Set((defaults.string(forKey: PreferenceKey.expandedMajorXcodeVersions.rawValue) ?? "").split(separator: ",").compactMap { Int($0) }) + minorVersions = Set((defaults.string(forKey: PreferenceKey.expandedMinorXcodeVersions.rawValue) ?? "").split(separator: ",").map(String.init)) } - private var expandedMinorVersions: Set { - get { - Set(expandedMinorVersionStorage.split(separator: ",").map(String.init)) + mutating func toggleMajor(_ major: Int, minorIDs: [String]) { + if majorVersions.remove(major) != nil { + minorVersions.subtract(minorIDs) + } else { + majorVersions.insert(major) } - nonmutating set { - expandedMinorVersionStorage = newValue.sorted().joined(separator: ",") + } + + mutating func toggleMinor(_ id: String) { + if minorVersions.remove(id) == nil { minorVersions.insert(id) } + } + + func save() { + UserDefaults.standard.set(majorVersions.sorted().map(String.init).joined(separator: ","), forKey: PreferenceKey.expandedMajorXcodeVersions.rawValue) + UserDefaults.standard.set(minorVersions.sorted().joined(separator: ","), forKey: PreferenceKey.expandedMinorXcodeVersions.rawValue) + } +} + +/// Build summaries only when the dataset or filters change, not when a group opens. +struct XcodeGroupedListSnapshot { + struct Summary { + let displayName: String + let latestRelease: Xcode? + let latestSelectableRelease: Xcode? + let latestSelectionTarget: Xcode? + let selectedVersion: Xcode? + let installingVersion: Xcode? + let versionCount: Int + let tag: ReleaseTagView? + + init(versions: [Xcode], displayName: String, latestSelectableRelease: Xcode?, latestStableVersion: Version?, isMajor: Bool) { + self.displayName = displayName + latestRelease = versions.latestRelease + self.latestSelectableRelease = latestSelectableRelease + latestSelectionTarget = versions.latestInstalledVersion + selectedVersion = versions.first { $0.selected } + installingVersion = versions.first { $0.installState.installing } + versionCount = versions.count + if let latestStableVersion, versions.contains(where: { $0.version.isNotPrerelease && $0.version.isEquivalent(to: latestStableVersion) }) { + tag = .latest + } else if !isMajor, versions.allSatisfy(\.version.isPrerelease), let newest = versions.max(by: { $0.version < $1.version }) { + tag = ReleaseTagView(prereleaseOf: newest.version) + } else { + tag = nil + } } } - private var majorVersionGroups: [XcodeListElementMajorVersionGroup] { - xcodes.groupedByMajorVersion(item: \.listItem) + struct Minor { + let id: String + let summary: Summary + let entries: [XcodeListEntry] } - private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? { - allXcodes.latestReleaseForSelectedPrerelease(xcode) + struct Major { + let id: Int + let summary: Summary + let minors: [Minor] } - var body: some View { - ForEach(majorVersionGroups) { majorVersionGroup in - let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion) - let majorVersions = majorVersionGroup.versions.map(\.xcode) - let latestMajorRelease = majorVersions.latestRelease - let latestInstalledMajorVersion = majorVersions.latestInstalledVersion - - XcodeVersionGroupRow( - displayName: majorVersionGroup.displayName, - latestRelease: latestMajorRelease, - latestSelectableRelease: latestMajorRelease, - latestSelectionTarget: latestInstalledMajorVersion, - selectedVersion: majorVersions.first { $0.selected }, - installingVersion: majorVersions.first { $0.installState.installing }, - isExpanded: isMajorExpanded, - indentation: 0, - appState: appState, - onToggleExpanded: { - var updatedExpandedMajorVersions = expandedMajorVersions - var updatedExpandedMinorVersions = expandedMinorVersions - - if isMajorExpanded { - updatedExpandedMajorVersions.remove(majorVersionGroup.majorVersion) - majorVersionGroup.minorVersionGroups.forEach { - updatedExpandedMinorVersions.remove($0.id) - } - } else { - updatedExpandedMajorVersions.insert(majorVersionGroup.majorVersion) - } + enum Row: Identifiable { + case major(Major, expanded: Bool) + case minor(Minor, expanded: Bool, isLast: Bool) + case version(XcodeListEntry, isLastMinor: Bool, isLastEntry: Bool, latestRelease: Xcode?) + + var id: String { + switch self { + case let .major(group, _): return "major-\(group.id)" + case let .minor(group, _, _): return "minor-\(group.id)" + case let .version(entry, _, _, _): return "version-\(entry.xcode.id)" + } + } + } - self.expandedMajorVersions = updatedExpandedMajorVersions - self.expandedMinorVersions = updatedExpandedMinorVersions + let majors: [Major] + let latestStableVersion: Version? + private let replacements: [Xcode.ID: Xcode] + + init(xcodes: [XcodeListEntry], allXcodes: [Xcode]) { + let latestStable = allXcodes.latestRelease?.version + latestStableVersion = latestStable + replacements = Dictionary(allXcodes.compactMap { xcode in + allXcodes.latestReleaseForSelectedPrerelease(xcode).map { (xcode.id, $0) } + }, uniquingKeysWith: { first, _ in first }) + majors = xcodes.groupedByMajorVersion(item: \.listItem).map { major in + let versions = major.versions.map(\.xcode) + let latest = versions.latestRelease + return Major( + id: major.majorVersion, + summary: Summary(versions: versions, displayName: "Xcode \(major.displayName)", latestSelectableRelease: latest, latestStableVersion: latestStable, isMajor: true), + minors: major.minorVersionGroups.map { minor in + Minor(id: minor.id, + summary: Summary(versions: minor.versions.map(\.xcode), displayName: minor.displayName, latestSelectableRelease: latest, latestStableVersion: latestStable, isMajor: false), + entries: minor.versions) } ) - .tag(majorVersions.first { $0.selected }?.id) - - if isMajorExpanded { - ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in - let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id) - let minorVersions = minorVersionGroup.versions.map(\.xcode) - let latestInstalledMinorVersion = minorVersions.latestInstalledVersion - - XcodeVersionGroupRow( - displayName: minorVersionGroup.displayName, - latestRelease: minorVersions.latestRelease, - latestSelectableRelease: latestMajorRelease, - latestSelectionTarget: latestInstalledMinorVersion, - selectedVersion: minorVersions.first { $0.selected }, - installingVersion: minorVersions.first { $0.installState.installing }, - isExpanded: isMinorExpanded, - indentation: 20, - appState: appState, - onToggleExpanded: { - var updatedExpandedMinorVersions = expandedMinorVersions + } + } - if isMinorExpanded { - updatedExpandedMinorVersions.remove(minorVersionGroup.id) - } else { - updatedExpandedMinorVersions.insert(minorVersionGroup.id) - } + func rows(expansion: XcodeListExpansion) -> [Row] { + var rows: [Row] = [] + for major in majors { + let expanded = expansion.majorVersions.contains(major.id) + rows.append(.major(major, expanded: expanded)) + guard expanded else { continue } + for (minorIndex, minor) in major.minors.enumerated() { + let minorExpanded = expansion.minorVersions.contains(minor.id) + let isLastMinor = minorIndex == major.minors.count - 1 + rows.append(.minor(minor, expanded: minorExpanded, isLast: isLastMinor)) + guard minorExpanded else { continue } + for (entryIndex, entry) in minor.entries.enumerated() { + rows.append(.version(entry, isLastMinor: isLastMinor, isLastEntry: entryIndex == minor.entries.count - 1, latestRelease: replacements[entry.xcode.id])) + } + } + } + return rows + } +} - self.expandedMinorVersions = updatedExpandedMinorVersions - } - ) - .tag(minorVersions.first { $0.selected }?.id) - - if isMinorExpanded { - ForEach(minorVersionGroup.versions) { entry in - XcodeListViewRow( - xcode: entry.xcode, - selected: selectedXcodeID == entry.xcode.id, - appState: appState, - latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode) - ) - .padding(.leading, 40) - .tag(entry.xcode.id) - } +private struct GroupedXcodeListContent: View { + let snapshot: XcodeGroupedListSnapshot + @Binding var expansion: XcodeListExpansion + @Binding var selectedXcodeID: Xcode.ID? + let appState: AppState + + var body: some View { + // One flat collection lets List identify rows without walking nested, variable-size ForEach views. + ForEach(snapshot.rows(expansion: expansion)) { row in + switch row { + case let .major(group, expanded): + groupRow(group.summary, expanded: expanded, level: 0) { + withAnimation(.easeInOut(duration: 0.18)) { + expansion.toggleMajor(group.id, minorIDs: group.minors.map(\.id)) } } + case let .minor(group, expanded, isLast): + groupRow(group.summary, expanded: expanded, level: 1) { + withAnimation(.easeInOut(duration: 0.18)) { expansion.toggleMinor(group.id) } + } + .treeGuides { TreeGuides(guides: [TreeGuide(level: 0, extent: isLast ? .elbow : .tee)]) } + case let .version(entry, isLastMinor, isLastEntry, latestRelease): + XcodeListViewRow( + xcode: entry.xcode, + selected: selectedXcodeID == entry.xcode.id, + appState: appState, + latestReleaseForSelectedPrerelease: latestRelease, + style: .grouped, + isLatestRelease: entry.xcode.version.isNotPrerelease && snapshot.latestStableVersion.map { entry.xcode.version.isEquivalent(to: $0) } == true + ) + .padding(.leading, TreeGuide.contentInset(forLevel: 2)) + .treeGuides { + TreeGuides(guides: [ + TreeGuide(level: 0, extent: isLastMinor ? .none : .through), + TreeGuide(level: 1, extent: isLastEntry ? .elbow : .tee, branchesToLeaf: true) + ]) + } + .selectableRow(isSelected: selectedXcodeID == entry.xcode.id) { selectedXcodeID = entry.xcode.id } } } } + + private func groupRow(_ summary: XcodeGroupedListSnapshot.Summary, expanded: Bool, level: Int, toggle: @escaping () -> Void) -> some View { + XcodeVersionGroupRow( + displayName: summary.displayName, + latestRelease: summary.latestRelease, + latestSelectableRelease: summary.latestSelectableRelease, + latestSelectionTarget: summary.latestSelectionTarget, + selectedVersion: summary.selectedVersion, + installingVersion: summary.installingVersion, + isExpanded: expanded, + level: level, + versionCount: summary.versionCount, + tag: summary.tag, + showsActiveTag: summary.selectedVersion != nil && !expanded, + appState: appState, + onToggleExpanded: toggle + ) + } +} + +/// A vertical guide line connecting a group row to its children, drawn like a file tree (├ └ │). +private struct TreeGuide: Hashable { + enum Extent { + /// No line; the parent's last child has already been drawn. + case none + /// A full-height line passing a sibling's descendants. + case through + /// A full-height line with a branch to this row (├). + case tee + /// A line ending at this row with a branch to it (└). + case elbow + } + + /// Chevron width plus spacing, so each level's content starts one step to the right of its parent's chevron. + static let levelIndent: CGFloat = 22 + static let chevronWidth: CGFloat = 12 + + let level: Int + let extent: Extent + /// Whether this row is a leaf (no chevron), so the branch reaches further to meet its icon. + var branchesToLeaf = false + + /// Centered under the parent's chevron. + var x: CGFloat { + CGFloat(level) * Self.levelIndent + Self.chevronWidth / 2 + } + + /// Where the branch drawn for this row stops, just short of the row's chevron or icon. + var branchEnd: CGFloat { + let childContentStart = branchesToLeaf ? Self.contentInset(forLevel: level + 1) : CGFloat(level + 1) * Self.levelIndent + return childContentStart - 4 + } + + /// Leaf rows have no chevron, so their icon is inset to where a chevron-less row at that level would start. + static func contentInset(forLevel level: Int) -> CGFloat { + CGFloat(level) * levelIndent + chevronWidth + 8 + } +} + +private struct TreeGuides: View { + let guides: [TreeGuide] + /// Where branches meet the row: the middle of its title line. Falls back to the row's middle. + var branchY: CGFloat? = nil + + var body: some View { + Canvas { context, size in + let lineWidth: CGFloat = 1.5 + // Rows have a little vertical breathing room; extend past it so lines connect between rows. + let overshoot: CGFloat = 4 + let midY = branchY ?? size.height / 2 + for guide in guides { + let color = Color.secondary.opacity(0.35) + var path = Path() + switch guide.extent { + case .none: + continue + case .through, .tee: + path.move(to: CGPoint(x: guide.x, y: -overshoot)) + path.addLine(to: CGPoint(x: guide.x, y: size.height + overshoot)) + case .elbow: + path.move(to: CGPoint(x: guide.x, y: -overshoot)) + path.addLine(to: CGPoint(x: guide.x, y: midY - 5)) + path.addQuadCurve(to: CGPoint(x: guide.x + 5, y: midY), control: CGPoint(x: guide.x, y: midY)) + } + if guide.extent == .tee || guide.extent == .elbow { + let branchStart = guide.extent == .tee ? guide.x : guide.x + 5 + path.move(to: CGPoint(x: branchStart, y: midY)) + path.addLine(to: CGPoint(x: guide.branchEnd, y: midY)) + } + context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } } private struct XcodeVersionGroupRow: View { @@ -208,29 +409,56 @@ private struct XcodeVersionGroupRow: View { let selectedVersion: Xcode? let installingVersion: Xcode? let isExpanded: Bool - let indentation: CGFloat - let appState: AppState + let level: Int + let versionCount: Int + let tag: ReleaseTagView? + let showsActiveTag: Bool + @ObservedObject var appState: AppState let onToggleExpanded: () -> Void var body: some View { HStack { Button(action: onToggleExpanded) { - HStack(spacing: 8) { + // Nested rows line the chevron up with the title, where the tree guide branch ends; + // top-level rows center it on the icon. + HStack(alignment: level == 0 ? .center : .firstTextBaseline, spacing: 8) { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) .frame(width: 12, height: 12) - icon + if level == 0 { + icon + } VStack(alignment: .leading, spacing: 2) { - Text(verbatim: "Xcode \(displayName)") - .font(.body.weight(indentation == 0 ? .medium : .regular)) + HStack(spacing: 6) { + Text(verbatim: displayName) + .font(level == 0 ? .headline : .body.weight(.medium)) + .fixedSize(horizontal: true, vertical: false) + .treeGuideTitle() + + if appState.showTags, let tag { + tag + } + + if appState.showTags && showsActiveTag { + ReleaseTagView.active + } - if let latestRelease { - Text(verbatim: "Latest: \(latestRelease.description)") + Text(verbatim: "\(versionCount)") + .font(.caption2.weight(.semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + .accessibilityHidden(true) + } + + if let subtitle { + Text(verbatim: subtitle) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } @@ -241,15 +469,30 @@ private struct XcodeVersionGroupRow: View { } .buttonStyle(.plain) - selectControl - .padding(.trailing, 16) - installControl + // Like the progress ring and Active tag, the checkmark and Open/Install belong to the deepest + // visible row, so an expanded group leaves them to the rows below it + if !isExpanded { + selectControl + .padding(.trailing, 16) + installControl + // Same column width as the Install/Open buttons, so the progress ring lines up with them + .frame(minWidth: 67) + } } - .padding(.leading, indentation) - .padding(.vertical, indentation == 0 ? 8 : 6) + .padding(.leading, CGFloat(level) * TreeGuide.levelIndent) + .padding(.vertical, level == 0 ? 8 : 5) .contentShape(Rectangle()) } + /// "Latest: 27.0 · Active: 27.0 Beta 6" + private var subtitle: String? { + let parts = [ + latestRelease.map { "Latest: \($0.description)" }, + selectedVersion.map { "Active: \($0.description)" }, + ].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + @ViewBuilder private var icon: some View { if let icon = latestRelease?.icon { @@ -260,7 +503,7 @@ private struct XcodeVersionGroupRow: View { Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode") .resizable() .frame(width: 32, height: 32) - .opacity(0.2) + .opacity(0.5) } } @@ -294,8 +537,9 @@ private struct XcodeVersionGroupRow: View { .help("ActiveVersionDescription") } else if let latestSelectionTarget { Button(action: { appState.select(xcode: latestSelectionTarget) }) { + // Installed but not active: green outline; the active Xcode gets the filled check Image(systemName: "checkmark.circle") - .foregroundColor(.secondary) + .foregroundColor(.green) } .buttonStyle(PlainButtonStyle()) .help("MakeActiveVersionDescription") @@ -437,3 +681,46 @@ struct XcodeListView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) } } + +// MARK: - Tree guide title anchor + +/// The vertical middle of a row's title, so tree guide branches meet the title rather than the row's middle. +private struct TreeGuideTitleMidYKey: SwiftUI.PreferenceKey { + static let defaultValue: CGFloat? = nil + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + value = value ?? nextValue() + } +} + +private let treeGuideRowSpace = "treeGuideRow" + +extension View { + /// Marks the row's title; tree guides drawn with `treeGuides(_:)` branch at its middle. + func treeGuideTitle() -> some View { + background { + GeometryReader { proxy in + Color.clear.preference(key: TreeGuideTitleMidYKey.self, value: proxy.frame(in: .named(treeGuideRowSpace)).midY) + } + } + } + + fileprivate func treeGuides(_ guides: @escaping () -> TreeGuides) -> some View { + coordinateSpace(name: treeGuideRowSpace) + .backgroundPreferenceValue(TreeGuideTitleMidYKey.self) { titleMidY in + let base = guides() + TreeGuides(guides: base.guides, branchY: titleMidY) + } + } + + /// Selects the row on click and draws a soft highlight that keeps text and tags legible. + func selectableRow(isSelected: Bool, onSelect: @escaping () -> Void) -> some View { + contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .listRowBackground( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.2) : .clear) + .padding(.horizontal, 10) + ) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 2d86e634..a36db504 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -4,49 +4,64 @@ import Version import XcodesKit struct XcodeListViewRow: View { + enum Style { + /// Standalone rows include the Xcode icon. + case flat + /// Rows under a version group rely on the group's icon. + case grouped + } + let xcode: Xcode let selected: Bool - let appState: AppState + @ObservedObject var appState: AppState let latestReleaseForSelectedPrerelease: Xcode? + let style: Style + let isLatestRelease: Bool - init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil) { + init(xcode: Xcode, selected: Bool, appState: AppState, latestReleaseForSelectedPrerelease: Xcode? = nil, style: Style = .flat, isLatestRelease: Bool = false) { self.xcode = xcode self.selected = selected self.appState = appState self.latestReleaseForSelectedPrerelease = latestReleaseForSelectedPrerelease + self.style = style + self.isLatestRelease = isLatestRelease + } + + private var title: String { + // Tags carry the prerelease name in either layout; keep it in the title when tags are hidden. + guard appState.showTags else { return xcode.description } + let version = xcode.version + return Version(major: version.major, minor: version.minor, patch: version.patch).appleDescription + } + + /// Secondary line: the build and the install path, when present. + private var caption: String? { + var parts: [String] = xcode.version.buildMetadataIdentifiers + if case let .installed(path) = xcode.installState { + parts.append(path.string) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } var body: some View { HStack { - appIconView(for: xcode) + // Rows under a version group rely on the group's icon + if style == .flat { + appIconView(for: xcode) + } VStack(alignment: .leading) { - HStack { - Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.body) - - if !xcode.identicalBuildsForCurrentVariant.isEmpty { - Image(systemName: "square.fill.on.square.fill") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibility(label: Text("IdenticalBuilds")) - .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) - .help("IdenticalBuilds.help") - } - - if xcode.architectures?.isAppleSilicon ?? false { - Image(systemName: "m4.button.horizontal") - .font(.subheadline) - .foregroundColor(.secondary) - .accessibility(label: Text("Apple Silicon")) - .help("Apple Silicon") - } + // The version must never truncate; when space is tight, drop the small symbols first + ViewThatFits(in: .horizontal) { + titleLine(showsSymbols: true) + titleLine(showsSymbols: false) } - if case let .installed(path) = xcode.installState { - Text(verbatim: path.string) + if let caption { + Text(verbatim: caption) .font(.caption) - .foregroundColor(.secondary) + // A hierarchical style stays legible on the selection highlight, unlike Color.secondary + .foregroundStyle(.secondary) } } @@ -55,6 +70,8 @@ struct XcodeListViewRow: View { selectControl(for: xcode) .padding(.trailing, 16) installControl(for: xcode) + // Same column width as the Install/Open buttons, so the progress ring lines up with them + .frame(minWidth: 67) } .padding(.vertical, 4) .contextMenu { @@ -91,6 +108,46 @@ struct XcodeListViewRow: View { } } + private func titleLine(showsSymbols: Bool) -> some View { + HStack { + Text(verbatim: title) + .font(.body) + .fixedSize() + .treeGuideTitle() + + if appState.showTags { + if let prereleaseTag = ReleaseTagView(prereleaseOf: xcode.version) { + prereleaseTag + } + if isLatestRelease { + ReleaseTagView.latest + } + if xcode.selected { + ReleaseTagView.active + } + } + + if showsSymbols { + if !xcode.identicalBuildsForCurrentVariant.isEmpty { + Image(systemName: "square.fill.on.square.fill") + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibility(label: Text("IdenticalBuilds")) + .accessibility(value: Text(xcode.identicalBuildsForCurrentVariant.map(\.version.appleDescription).joined(separator: ", "))) + .help("IdenticalBuilds.help") + } + + if xcode.architectures?.isAppleSilicon ?? false { + Image(systemName: "m4.button.horizontal") + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibility(label: Text("Apple Silicon")) + .help("Apple Silicon") + } + } + } + } + @ViewBuilder func appIconView(for xcode: Xcode) -> some View { if let icon = xcode.icon { @@ -101,7 +158,7 @@ struct XcodeListViewRow: View { Image(xcode.version.isPrerelease ? "xcode-beta" : "xcode") .resizable() .frame(width: 32, height: 32) - .opacity(0.2) + .opacity(0.5) } } @@ -130,8 +187,9 @@ struct XcodeListViewRow: View { .help("ActiveVersionDescription") } else { Button(action: { appState.select(xcode: xcode) }) { + // Installed but not active: green outline; the active Xcode gets the filled check Image(systemName: "checkmark.circle") - .foregroundColor(.secondary) + .foregroundColor(.green) } .buttonStyle(PlainButtonStyle()) .help("MakeActiveVersionDescription") @@ -160,7 +218,7 @@ struct XcodeListViewRow: View { case .installed: Button("Open") { appState.open(xcode: xcode) } .textCase(.uppercase) - .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: selected)) + .buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false)) .help("OpenDescription") case .notInstalled: InstallButton(xcode: xcode) @@ -169,7 +227,7 @@ struct XcodeListViewRow: View { case let .installing(installationStep): InstallationStepRowView( installationStep: installationStep, - highlighted: selected, + highlighted: false, cancel: { appState.presentedAlert = .cancelInstall(xcode: xcode) } ) case .uninstalling: diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 6608a149..69ab2d18 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -6935,6 +6935,16 @@ } } }, + "CollapseMajorMinorVersions" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapse Major/Minor versions" + } + } + } + }, "Compatibility" : { "localizations" : { "ar" : { @@ -23545,6 +23555,16 @@ } } }, + "ShowTags" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show Tags" + } + } + } + }, "SignIn" : { "localizations" : { "ar" : { @@ -24309,6 +24329,28 @@ } } }, + "Tag.Active" : { + "comment" : "Tag on the Xcode that is currently selected with xcode-select", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + } + } + }, + "Tag.Latest" : { + "comment" : "Tag on the newest non-prerelease Xcode in the list", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest" + } + } + } + }, "TerminateAfterLastWindowClosed" : { "localizations" : { "ar" : { @@ -24682,6 +24724,16 @@ } } }, + "UI" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "UI" + } + } + } + }, "Unarchiving" : { "extractionState" : "manual", "localizations" : { diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 0d46447a..af03fda1 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -28,6 +28,7 @@ struct XcodesApp: App { } } } + .defaultSize(width: 1040, height: 720) .commands { CommandGroup(replacing: .appInfo) { Button("Menu.About") { diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 299e4f01..f16b47f2 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -35,6 +35,59 @@ class AppStateUpdateTests: XCTestCase { subject = AppState() } + func test_GroupedListExpansion_OnlyRemovesDescendantsOfCollapsedMajor() { + var expansion = XcodeListExpansion(majorVersions: [26, 27], minorVersions: ["26.5", "26.6", "27.0"]) + expansion.toggleMajor(26, minorIDs: ["26.5", "26.6"]) + XCTAssertEqual(expansion.majorVersions, [27]) + XCTAssertEqual(expansion.minorVersions, ["27.0"]) + expansion.toggleMajor(26, minorIDs: ["26.5", "26.6"]) + XCTAssertEqual(expansion.majorVersions, [26, 27]) + XCTAssertEqual(expansion.minorVersions, ["27.0"]) + } + + func test_GroupedListSnapshot_ShowsOnlyExpandedDescendantsWithStableIdentity() { + let xcodes = ["27.0.0+27A1", "26.6.0+17F1", "26.5.0+17E1"].map { + Xcode(version: Version($0)!, installState: .notInstalled, selected: false, icon: nil) + } + let entries = xcodes.enumerated().map { XcodeListEntry(index: $0.offset, xcode: $0.element) } + let snapshot = XcodeGroupedListSnapshot(xcodes: entries, allXcodes: xcodes) + var expansion = XcodeListExpansion(majorVersions: [], minorVersions: []) + XCTAssertEqual(snapshot.rows(expansion: expansion).map(\.id), ["major-27", "major-26"]) + + expansion.toggleMajor(26, minorIDs: ["26.6", "26.5"]) + XCTAssertEqual(snapshot.rows(expansion: expansion).map(\.id), ["major-27", "major-26", "minor-26.6", "minor-26.5"]) + expansion.toggleMinor("26.6") + let expandedRows = snapshot.rows(expansion: expansion) + XCTAssertEqual(expandedRows.count, 5) + guard case let .version(entry, _, _, _) = expandedRows[3] else { + return XCTFail("Expanded minor should immediately contain its version row") + } + XCTAssertEqual(entry.xcode.id, xcodes[1].id) + let versionID = expandedRows[3].id + let reindexed = xcodes.reversed().enumerated().map { XcodeListEntry(index: $0.offset, xcode: $0.element) } + let refreshed = XcodeGroupedListSnapshot(xcodes: reindexed, allXcodes: xcodes) + XCTAssertEqual(refreshed.rows(expansion: expansion)[3].id, versionID) + expansion.toggleMinor("26.6") + XCTAssertEqual(snapshot.rows(expansion: expansion).count, 4) + } + + func test_GroupedListSnapshot_PreservesActiveVersionAndLatestReleaseSummary() throws { + let stable = Xcode(version: Version("27.0.0+27A1")!, installState: .notInstalled, selected: false, icon: nil) + let activeBeta = Xcode(version: Version("27.0.0-beta.6+27A0")!, installState: .installed(Path("/Applications/Xcode-Beta.app")!), selected: true, icon: nil) + let xcodes = [stable, activeBeta] + let snapshot = XcodeGroupedListSnapshot(xcodes: xcodes.enumerated().map { XcodeListEntry(index: $0.offset, xcode: $0.element) }, allXcodes: xcodes) + let summary = try XCTUnwrap(snapshot.majors.first?.summary) + XCTAssertEqual(summary.latestRelease?.id, stable.id) + XCTAssertEqual(summary.selectedVersion?.id, activeBeta.id) + XCTAssertEqual(summary.latestSelectionTarget?.id, activeBeta.id) + let rows = snapshot.rows(expansion: XcodeListExpansion(majorVersions: [27], minorVersions: ["27.0"])) + let replacement = rows.compactMap { row -> Xcode? in + if case let .version(entry, _, _, replacement) = row, entry.xcode.selected { return replacement } + return nil + }.first + XCTAssertEqual(replacement?.id, stable.id) + } + func test_UpdateIfNeeded_OldTaskDoesNotClearReplacementTask() async throws { subject.availableXcodes = [ AvailableXcode(