From 3747f2151fadcb6251e6cf5ce6733f55057e1c62 Mon Sep 17 00:00:00 2001 From: Ancil Maxwell Hoffman Date: Sun, 14 Sep 2025 12:32:16 +0200 Subject: [PATCH] Major and minor version collapsable lists implemented --- Xcodes.xcodeproj/project.pbxproj | 9 + .../xcshareddata/WorkspaceSettings.xcsettings | 5 + Xcodes/Backend/Xcode.swift | 94 ++++++++++- Xcodes/Frontend/XcodeList/XcodeListView.swift | 68 +++++++- .../XcodeList/XcodeMajorVersionRow.swift | 157 ++++++++++++++++++ .../XcodeList/XcodeMinorVersionRow.swift | 140 ++++++++++++++++ Xcodes/Resources/Localizable.xcstrings | 26 ++- 7 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 Xcodes.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 Xcodes/Frontend/XcodeList/XcodeMajorVersionRow.swift create mode 100644 Xcodes/Frontend/XcodeList/XcodeMinorVersionRow.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index f9ce36a..b9ea497 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -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 = ""; }; + 05AB7F182E76CD8C007C5CFE /* XcodeMinorVersionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeMinorVersionRow.swift; sourceTree = ""; }; 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 = ""; }; 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index b172149..990137b 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -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 } + } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 9a8c9c4..24db069 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -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() + @State private var expandedMinorVersions = Set() init(selectedXcodeID: Binding, 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) { diff --git a/Xcodes/Frontend/XcodeList/XcodeMajorVersionRow.swift b/Xcodes/Frontend/XcodeList/XcodeMajorVersionRow.swift new file mode 100644 index 0000000..8eadb37 --- /dev/null +++ b/Xcodes/Frontend/XcodeList/XcodeMajorVersionRow.swift @@ -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) + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeMinorVersionRow.swift b/Xcodes/Frontend/XcodeList/XcodeMinorVersionRow.swift new file mode 100644 index 0000000..be8a2b3 --- /dev/null +++ b/Xcodes/Frontend/XcodeList/XcodeMinorVersionRow.swift @@ -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) + } +} \ No newline at end of file diff --git a/Xcodes/Resources/Localizable.xcstrings b/Xcodes/Resources/Localizable.xcstrings index 9d95df7..df80e79 100644 --- a/Xcodes/Resources/Localizable.xcstrings +++ b/Xcodes/Resources/Localizable.xcstrings @@ -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" } \ No newline at end of file