Major and minor version collapsable lists implemented

This commit is contained in:
Ancil Maxwell Hoffman 2025-09-14 12:32:16 +02:00
parent 6a0e521bc2
commit 3747f2151f
7 changed files with 490 additions and 9 deletions

View file

@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
05AB7F172E76C96E007C5CFE /* XcodeMajorVersionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AB7F162E76C96E007C5CFE /* XcodeMajorVersionRow.swift */; };
05AB7F192E76CD8C007C5CFE /* XcodeMinorVersionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05AB7F182E76CD8C007C5CFE /* XcodeMinorVersionRow.swift */; };
15F5B8902CCF09B900705E2F /* CryptoKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15F5B88F2CCF09B900705E2F /* CryptoKit.framework */; };
33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 334A932B2CA885A400A5E079 /* LibFido2Swift */; };
3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; };
@ -192,6 +194,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
05AB7F162E76C96E007C5CFE /* XcodeMajorVersionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeMajorVersionRow.swift; sourceTree = "<group>"; };
05AB7F182E76CD8C007C5CFE /* XcodeMinorVersionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeMinorVersionRow.swift; sourceTree = "<group>"; };
15F5B88F2CCF09B900705E2F /* CryptoKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoKit.framework; path = System/Library/Frameworks/CryptoKit.framework; sourceTree = SDKROOT; };
3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyPinView.swift; sourceTree = "<group>"; };
332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = "<group>"; };
@ -476,6 +480,8 @@
CA44901E2463AD34003D8213 /* Tag.swift */,
CAE42486259A68A300B8B246 /* XcodeListCategory.swift */,
CAD2E7A32449574E00113D76 /* XcodeListView.swift */,
05AB7F162E76C96E007C5CFE /* XcodeMajorVersionRow.swift */,
05AB7F182E76CD8C007C5CFE /* XcodeMinorVersionRow.swift */,
CAFFFED7259CDA5000903F81 /* XcodeListViewRow.swift */,
E8D0296E284B029800647641 /* BottomStatusBar.swift */,
);
@ -856,6 +862,7 @@
" -p \"${SRCROOT}/Xcodes.xcodeproj\" \\",
" -o \"${SRCROOT}/Xcodes/Resources/Licenses.rtf\"",
"",
"",
);
};
/* End PBXShellScriptBuildPhase section */
@ -880,6 +887,7 @@
CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */,
CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */,
CAA8589B25A2B83000ACF8C0 /* Aria2CError.swift in Sources */,
05AB7F192E76CD8C007C5CFE /* XcodeMinorVersionRow.swift in Sources */,
536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */,
CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */,
CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */,
@ -918,6 +926,7 @@
CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */,
B0403CF02AD92D7B00137C09 /* ReleaseNotesView.swift in Sources */,
CAFE4AB425B7D3AF0064FE51 /* AdvancedPreferencePane.swift in Sources */,
05AB7F172E76C96E007C5CFE /* XcodeMajorVersionRow.swift in Sources */,
CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */,
E832EAF82B0FBCF4001B570D /* RuntimeInstallationStepDetailView.swift in Sources */,
CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */,

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View file

@ -85,5 +85,97 @@ struct Xcode: Identifiable, CustomStringConvertible {
return nil
}
}
}
struct XcodeMinorVersionGroup: Identifiable {
let majorVersion: Int
let minorVersion: Int
let versions: [Xcode]
var isExpanded: Bool = false
var id: String {
"\(majorVersion).\(minorVersion)"
}
var latestRelease: Xcode? {
versions
.filter { $0.version.isNotPrerelease }
.sorted { $0.version < $1.version }
.last
}
var displayName: String {
"\(majorVersion).\(minorVersion)"
}
var hasInstalled: Bool {
versions.contains { $0.installState.installed }
}
var hasInstalling: Bool {
versions.contains { $0.installState.installing }
}
var selectedVersion: Xcode? {
versions.first { $0.selected }
}
}
struct XcodeMajorVersionGroup: Identifiable {
let majorVersion: Int
let minorVersionGroups: [XcodeMinorVersionGroup]
var isExpanded: Bool = false
var id: Int {
majorVersion
}
var versions: [Xcode] {
minorVersionGroups.flatMap { $0.versions }
}
var latestRelease: Xcode? {
versions
.filter { $0.version.isNotPrerelease }
.sorted { $0.version < $1.version }
.last
}
var displayName: String {
"\(majorVersion)"
}
var hasInstalled: Bool {
minorVersionGroups.contains { $0.hasInstalled }
}
var hasInstalling: Bool {
minorVersionGroups.contains { $0.hasInstalling }
}
var selectedVersion: Xcode? {
minorVersionGroups.compactMap { $0.selectedVersion }.first
}
}
extension Array where Element == Xcode {
func groupedByMajorVersion() -> [XcodeMajorVersionGroup] {
let majorGroups = Dictionary(grouping: self) { $0.version.major }
return majorGroups.map { majorVersion, xcodes in
let minorGroups = Dictionary(grouping: xcodes) { $0.version.minor }
let minorVersionGroups = minorGroups.map { minorVersion, minorXcodes in
XcodeMinorVersionGroup(
majorVersion: majorVersion,
minorVersion: minorVersion,
versions: minorXcodes.sorted { $0.version > $1.version }
)
}.sorted { $0.minorVersion > $1.minorVersion }
return XcodeMajorVersionGroup(
majorVersion: majorVersion,
minorVersionGroups: minorVersionGroups
)
}.sorted { $0.majorVersion > $1.majorVersion }
}
}

View file

@ -10,6 +10,8 @@ struct XcodeListView: View {
private let architecture: XcodeListArchitecture
private let isInstalledOnly: Bool
@AppStorage(PreferenceKey.allowedMajorVersions.rawValue) private var allowedMajorVersions = Int.max
@State private var expandedMajorVersions = Set<Int>()
@State private var expandedMinorVersions = Set<String>()
init(selectedXcodeID: Binding<Xcode.ID?>, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool, architecture: XcodeListArchitecture) {
self._selectedXcodeID = selectedXcodeID
@ -29,12 +31,12 @@ struct XcodeListView: View {
case .beta:
xcodes = appState.allXcodes.filter { $0.version.isPrerelease }
}
if architecture == .appleSilicon {
xcodes = xcodes.filter { $0.architectures == [.arm64] }
}
let latestMajor = xcodes.sorted(\.version)
.filter { $0.version.isNotPrerelease }
.last?
@ -54,17 +56,69 @@ struct XcodeListView: View {
if !searchText.isEmpty {
xcodes = xcodes.filter { $0.description.contains(searchText) }
}
if isInstalledOnly {
xcodes = xcodes.filter { $0.installState.installed }
}
return xcodes
}
var majorVersionGroups: [XcodeMajorVersionGroup] {
visibleXcodes.groupedByMajorVersion()
}
var body: some View {
List(visibleXcodes, selection: $selectedXcodeID) { xcode in
XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState)
List(selection: $selectedXcodeID) {
ForEach(majorVersionGroups) { majorVersionGroup in
let isMajorExpanded = expandedMajorVersions.contains(majorVersionGroup.majorVersion)
XcodeMajorVersionRow(
majorVersionGroup: majorVersionGroup,
isExpanded: isMajorExpanded,
onToggleExpanded: {
if isMajorExpanded {
expandedMajorVersions.remove(majorVersionGroup.majorVersion)
// Collapse all minor versions when major version is collapsed
for minorGroup in majorVersionGroup.minorVersionGroups {
expandedMinorVersions.remove(minorGroup.id)
}
} else {
expandedMajorVersions.insert(majorVersionGroup.majorVersion)
}
},
appState: appState
)
.tag(majorVersionGroup.selectedVersion?.id)
if isMajorExpanded {
ForEach(majorVersionGroup.minorVersionGroups) { minorVersionGroup in
let isMinorExpanded = expandedMinorVersions.contains(minorVersionGroup.id)
XcodeMinorVersionRow(
minorVersionGroup: minorVersionGroup,
isExpanded: isMinorExpanded,
onToggleExpanded: {
if isMinorExpanded {
expandedMinorVersions.remove(minorVersionGroup.id)
} else {
expandedMinorVersions.insert(minorVersionGroup.id)
}
},
appState: appState
)
.tag(minorVersionGroup.selectedVersion?.id)
if isMinorExpanded {
ForEach(minorVersionGroup.versions) { xcode in
XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState)
.padding(.leading, 40)
.tag(xcode.id)
}
}
}
}
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom, spacing: 0) {

View file

@ -0,0 +1,157 @@
import SwiftUI
import Version
import Path
struct XcodeMajorVersionRow: View {
let majorVersionGroup: XcodeMajorVersionGroup
let isExpanded: Bool
let onToggleExpanded: () -> Void
let appState: AppState
var body: some View {
HStack {
Button(action: onToggleExpanded) {
HStack(spacing: 8) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
majorVersionIcon
VStack(alignment: .leading, spacing: 2) {
Text("Xcode \(majorVersionGroup.displayName)")
.font(.body.weight(.medium))
if let latestRelease = majorVersionGroup.latestRelease {
Text("Latest: \(latestRelease.description)")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
.buttonStyle(.plain)
selectControl()
.padding(.trailing, 16)
installControl()
}
.padding(.vertical, 8)
.background(Color.clear)
.contentShape(Rectangle())
}
@ViewBuilder
var majorVersionIcon: some View {
if let latestRelease = majorVersionGroup.latestRelease {
if let icon = latestRelease.icon {
Image(nsImage: icon)
.resizable()
.frame(width: 32, height: 32)
} else {
Image("xcode")
.resizable()
.frame(width: 32, height: 32)
.opacity(0.7)
}
} else {
Image("xcode-beta")
.resizable()
.frame(width: 32, height: 32)
.opacity(0.7)
}
}
@ViewBuilder
func selectControl() -> some View {
if let selectedVersion = majorVersionGroup.selectedVersion {
if selectedVersion.selected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.help("ActiveVersionDescription")
} else {
EmptyView()
}
} else if majorVersionGroup.hasInstalled {
EmptyView()
} else {
EmptyView()
}
}
@ViewBuilder
func installControl() -> some View {
if majorVersionGroup.hasInstalling {
if let installingVersion = majorVersionGroup.versions.first(where: { $0.installState.installing }) {
if case let .installing(installationStep) = installingVersion.installState {
InstallationStepRowView(
installationStep: installationStep,
highlighted: false,
cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) }
)
}
}
} else if let latestRelease = majorVersionGroup.latestRelease {
switch latestRelease.installState {
case .installed:
Button("Open") { appState.open(xcode: latestRelease) }
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false))
.help("OpenDescription")
case .notInstalled:
Button("Install Latest Release") {
appState.checkMinVersionAndInstall(id: latestRelease.id)
}
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.help("InstallLatestReleaseDescription")
case .installing:
EmptyView()
}
}
}
}
struct XcodeMajorVersionRow_Previews: PreviewProvider {
static var previews: some View {
let sampleXcodes = [
Xcode(version: Version("16.4.0")!, installState: .installed(Path("/Applications/Xcode-16.4.0.app")!), selected: true, icon: nil),
Xcode(version: Version("16.3.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("16.2.0")!, installState: .notInstalled, selected: false, icon: nil),
]
let minorVersionGroups = [
XcodeMinorVersionGroup(
majorVersion: 16,
minorVersion: 4,
versions: [sampleXcodes[0]]
),
XcodeMinorVersionGroup(
majorVersion: 16,
minorVersion: 3,
versions: [sampleXcodes[1]]
),
XcodeMinorVersionGroup(
majorVersion: 16,
minorVersion: 2,
versions: [sampleXcodes[2]]
)
]
let majorVersionGroup = XcodeMajorVersionGroup(
majorVersion: 16,
minorVersionGroups: minorVersionGroups,
isExpanded: false
)
XcodeMajorVersionRow(
majorVersionGroup: majorVersionGroup,
isExpanded: false,
onToggleExpanded: {},
appState: AppState()
)
.previewLayout(.sizeThatFits)
}
}

View file

@ -0,0 +1,140 @@
import SwiftUI
import Version
import Path
struct XcodeMinorVersionRow: View {
let minorVersionGroup: XcodeMinorVersionGroup
let isExpanded: Bool
let onToggleExpanded: () -> Void
let appState: AppState
var body: some View {
HStack {
Button(action: onToggleExpanded) {
HStack(spacing: 8) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
minorVersionIcon
VStack(alignment: .leading, spacing: 2) {
Text("Xcode \(minorVersionGroup.displayName)")
.font(.callout.weight(.medium))
if let latestRelease = minorVersionGroup.latestRelease {
Text("Latest: \(latestRelease.description)")
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
}
}
.buttonStyle(.plain)
selectControl()
.padding(.trailing, 16)
installControl()
}
.padding(.vertical, 6)
.padding(.leading, 20)
.background(Color.clear)
.contentShape(Rectangle())
}
@ViewBuilder
var minorVersionIcon: some View {
if let latestRelease = minorVersionGroup.latestRelease {
if let icon = latestRelease.icon {
Image(nsImage: icon)
.resizable()
.frame(width: 28, height: 28)
} else {
Image("xcode")
.resizable()
.frame(width: 28, height: 28)
.opacity(0.6)
}
} else {
Image("xcode-beta")
.resizable()
.frame(width: 28, height: 28)
.opacity(0.6)
}
}
@ViewBuilder
func selectControl() -> some View {
if let selectedVersion = minorVersionGroup.selectedVersion {
if selectedVersion.selected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.help("ActiveVersionDescription")
} else {
EmptyView()
}
} else if minorVersionGroup.hasInstalled {
EmptyView()
} else {
EmptyView()
}
}
@ViewBuilder
func installControl() -> some View {
if minorVersionGroup.hasInstalling {
if let installingVersion = minorVersionGroup.versions.first(where: { $0.installState.installing }) {
if case let .installing(installationStep) = installingVersion.installState {
InstallationStepRowView(
installationStep: installationStep,
highlighted: false,
cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) }
)
}
}
} else if let latestRelease = minorVersionGroup.latestRelease {
switch latestRelease.installState {
case .installed:
Button("Open") { appState.open(xcode: latestRelease) }
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: true, highlighted: false))
.help("OpenDescription")
case .notInstalled:
Button("Install Latest") {
appState.checkMinVersionAndInstall(id: latestRelease.id)
}
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.help("InstallLatestVersionDescription")
case .installing:
EmptyView()
}
}
}
}
struct XcodeMinorVersionRow_Previews: PreviewProvider {
static var previews: some View {
let sampleXcodes = [
Xcode(version: Version("16.4.0")!, installState: .installed(Path("/Applications/Xcode-16.4.0.app")!), selected: true, icon: nil),
Xcode(version: Version("16.4.1")!, installState: .notInstalled, selected: false, icon: nil),
]
let minorVersionGroup = XcodeMinorVersionGroup(
majorVersion: 16,
minorVersion: 4,
versions: sampleXcodes,
isExpanded: false
)
XcodeMinorVersionRow(
minorVersionGroup: minorVersionGroup,
isExpanded: false,
onToggleExpanded: {},
appState: AppState()
)
.previewLayout(.sizeThatFits)
}
}

View file

@ -10845,6 +10845,14 @@
}
}
},
"Install Latest" : {
"comment" : "A button that installs the latest Xcode version.",
"isCommentAutoGenerated" : true
},
"Install Latest Release" : {
"comment" : "A button that installs the latest release of Xcode.",
"isCommentAutoGenerated" : true
},
"Install Universal" : {
"localizations" : {
"ar" : {
@ -13587,6 +13595,14 @@
}
}
},
"InstallLatestReleaseDescription" : {
"comment" : "A button label that instructs the user to install the latest release of Xcode.",
"isCommentAutoGenerated" : true
},
"InstallLatestVersionDescription" : {
"comment" : "A button label that instructs the user to install the latest version of Xcode.",
"isCommentAutoGenerated" : true
},
"InstallPathDescription" : {
"localizations" : {
"ar" : {
@ -13836,6 +13852,10 @@
}
}
},
"Latest: %@" : {
"comment" : "A sublabel within the Xcode major version row that shows the description of the latest release in that version group.",
"isCommentAutoGenerated" : true
},
"License" : {
"localizations" : {
"ar" : {
@ -23687,6 +23707,10 @@
}
}
},
"Xcode %@" : {
"comment" : "A title and optional subtitle for a row in the checkout view, displaying the Xcode version and its latest release.",
"isCommentAutoGenerated" : true
},
"Xcodes" : {
"localizations" : {
"ar" : {
@ -23806,5 +23830,5 @@
}
}
},
"version" : "1.0"
"version" : "1.1"
}