Files

440 lines
18 KiB
Swift

import Path
import SwiftUI
import Version
import XcodesKit
struct XcodeListView: View {
@EnvironmentObject var appState: AppState
@Binding var selectedXcodeID: Xcode.ID?
private let searchText: String
private let category: XcodeListCategory
private let architecture: XcodeListArchitecture
private let isInstalledOnly: Bool
@AppStorage(PreferenceKey.allowedMajorVersions.rawValue) private var allowedMajorVersions = Int.max
init(selectedXcodeID: Binding<Xcode.ID?>, searchText: String, category: XcodeListCategory, isInstalledOnly: Bool, architecture: XcodeListArchitecture) {
self._selectedXcodeID = selectedXcodeID
self.searchText = searchText
self.category = category
self.isInstalledOnly = isInstalledOnly
self.architecture = architecture
}
private var visibleXcodes: [XcodeListEntry] {
appState.allXcodes
.enumerated()
.map { XcodeListEntry(index: $0.offset, xcode: $0.element) }
.applying(XcodeListFilters(
versionFilter: category.versionFilter,
architectureFilters: architecture.architectureFilters,
allowedMajorVersions: allowedMajorVersions,
searchText: searchText,
installedOnly: isInstalledOnly
), item: \.listItem)
}
private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? {
appState.allXcodes.latestReleaseForSelectedPrerelease(xcode)
}
var body: some View {
List(selection: $selectedXcodeID) {
if appState.enableGroupedXcodeList {
GroupedXcodeListContent(
xcodes: visibleXcodes,
allXcodes: appState.allXcodes,
selectedXcodeID: $selectedXcodeID,
appState: appState
)
} else {
ForEach(visibleXcodes) { entry in
XcodeListViewRow(
xcode: entry.xcode,
selected: selectedXcodeID == entry.xcode.id,
appState: appState,
latestReleaseForSelectedPrerelease: latestReleaseForSelectedPrerelease(entry.xcode)
)
.tag(entry.xcode.id)
}
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom, spacing: 0) {
PlatformsPocket()
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
private struct XcodeListEntry: Identifiable {
let index: Int
let xcode: Xcode
var id: Int {
index
}
var listItem: XcodeListItem {
xcode.listItem
}
}
private struct GroupedXcodeListContent: View {
let xcodes: [XcodeListEntry]
let allXcodes: [Xcode]
@Binding var selectedXcodeID: Xcode.ID?
let appState: AppState
@AppStorage(PreferenceKey.expandedMajorXcodeVersions.rawValue) private var expandedMajorVersionStorage = ""
@AppStorage(PreferenceKey.expandedMinorXcodeVersions.rawValue) private var expandedMinorVersionStorage = ""
private var expandedMajorVersions: Set<Int> {
get {
Set(expandedMajorVersionStorage.split(separator: ",").compactMap { Int($0) })
}
nonmutating set {
expandedMajorVersionStorage = newValue.sorted().map(String.init).joined(separator: ",")
}
}
private var expandedMinorVersions: Set<String> {
get {
Set(expandedMinorVersionStorage.split(separator: ",").map(String.init))
}
nonmutating set {
expandedMinorVersionStorage = newValue.sorted().joined(separator: ",")
}
}
private var majorVersionGroups: [XcodeListElementMajorVersionGroup<XcodeListEntry>] {
xcodes.groupedByMajorVersion(item: \.listItem)
}
private func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? {
allXcodes.latestReleaseForSelectedPrerelease(xcode)
}
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)
}
self.expandedMajorVersions = updatedExpandedMajorVersions
self.expandedMinorVersions = updatedExpandedMinorVersions
}
)
.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)
}
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 XcodeVersionGroupRow: View {
let displayName: String
let latestRelease: Xcode?
let latestSelectableRelease: Xcode?
let latestSelectionTarget: Xcode?
let selectedVersion: Xcode?
let installingVersion: Xcode?
let isExpanded: Bool
let indentation: CGFloat
let appState: AppState
let onToggleExpanded: () -> Void
var body: some View {
HStack {
Button(action: onToggleExpanded) {
HStack(spacing: 8) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption.weight(.semibold))
.foregroundColor(.secondary)
.frame(width: 12, height: 12)
icon
VStack(alignment: .leading, spacing: 2) {
Text(verbatim: "Xcode \(displayName)")
.font(.body.weight(indentation == 0 ? .medium : .regular))
if let latestRelease {
Text(verbatim: "Latest: \(latestRelease.description)")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
selectControl
.padding(.trailing, 16)
installControl
}
.padding(.leading, indentation)
.padding(.vertical, indentation == 0 ? 8 : 6)
.contentShape(Rectangle())
}
@ViewBuilder
private var icon: some View {
if let icon = latestRelease?.icon {
Image(nsImage: icon)
.resizable()
.frame(width: 32, height: 32)
} else {
Image(latestRelease?.version.isPrerelease == true ? "xcode-beta" : "xcode")
.resizable()
.frame(width: 32, height: 32)
.opacity(0.2)
}
}
@ViewBuilder
private var selectControl: some View {
if let selectedVersion, selectedVersion.selected, let latestSelectableRelease, latestSelectableRelease.id != selectedVersion.id {
switch latestSelectionTarget?.installState {
case .installed:
if let latestSelectionTarget, latestSelectionTarget.id != selectedVersion.id {
Button(action: { appState.select(xcode: latestSelectionTarget) }) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.yellow)
}
.buttonStyle(PlainButtonStyle())
.help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget))
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.yellow)
.help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget))
}
case .notInstalled:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.yellow)
.help(staleSelectedHelpText(selectedVersion: selectedVersion, latestRelease: latestSelectableRelease, selectionTarget: latestSelectionTarget))
case .installing, .uninstalling, .none:
EmptyView()
}
} else if selectedVersion?.selected == true {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.help("ActiveVersionDescription")
} else if let latestSelectionTarget {
Button(action: { appState.select(xcode: latestSelectionTarget) }) {
Image(systemName: "checkmark.circle")
.foregroundColor(.secondary)
}
.buttonStyle(PlainButtonStyle())
.help("MakeActiveVersionDescription")
}
}
private func staleSelectedHelpText(selectedVersion: Xcode, latestRelease: Xcode, selectionTarget: Xcode?) -> Text {
switch selectionTarget?.installState {
case .installed:
if let selectionTarget, selectionTarget.id != selectedVersion.id {
return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Click to select \(selectionTarget.description).")
} else {
return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.")
}
case .notInstalled:
if let selectionTarget {
return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available. Install \(selectionTarget.description) to select it.")
} else {
return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.")
}
case .installing, .uninstalling, .none:
return Text(verbatim: "\(selectedVersion.description) selected, \(latestRelease.description) available.")
}
}
@ViewBuilder
private var installControl: some View {
if let installingVersion,
case let .installing(installationStep) = installingVersion.installState {
InstallationStepRowView(
installationStep: installationStep,
highlighted: false,
cancel: { appState.presentedAlert = .cancelInstall(xcode: installingVersion) }
)
} else if let 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") {
appState.checkMinVersionAndInstall(id: latestRelease.id)
}
.textCase(.uppercase)
.buttonStyle(AppStoreButtonStyle(primary: false, highlighted: false))
.help("InstallDescription")
case .installing, .uninstalling:
EmptyView()
}
}
}
}
private extension Array where Element == Xcode {
var latestRelease: Xcode? {
filter { $0.version.isNotPrerelease }
.sorted { $0.version < $1.version }
.last
}
var latestInstalledVersion: Xcode? {
filter(\.installState.installed)
.sorted { $0.version < $1.version }
.last
}
func latestReleaseForSelectedPrerelease(_ xcode: Xcode) -> Xcode? {
guard xcode.selected, xcode.version.isPrerelease else { return nil }
return first { candidate in
candidate.id != xcode.id &&
candidate.architectures == xcode.architectures &&
candidate.version.major == xcode.version.major &&
candidate.version.minor == xcode.version.minor &&
candidate.version.patch == xcode.version.patch &&
candidate.version.isNotPrerelease &&
candidate.installState.installing == false
}
}
}
struct PlatformsPocket: View {
@SwiftUI.Environment(\.openWindow) private var openWindow
var body: some View {
Button(action: {
openWindow(id: "platforms")
}
) {
if #available(macOS 26.0, *) {
platformsLabel
.glassEffect(in: .rect(cornerRadius: 8, style: .continuous))
} else {
platformsLabel
.background(.quaternary.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
.buttonStyle(.plain)
}
var platformsLabel: some View {
HStack(spacing: 5) {
Image(systemName: "square.3.layers.3d")
.font(.title3.weight(.medium))
Text("PlatformsDescription")
Spacer()
}
.font(.body.weight(.medium))
.padding(.horizontal)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
}
struct XcodeListView_Previews: PreviewProvider {
@MainActor
static var previews: some View {
Group {
XcodeListView(selectedXcodeID: .constant(nil), searchText: "", category: .all, isInstalledOnly: false, architecture: .appleSilicon)
.environmentObject({ () -> AppState in
let a = AppState()
a.allXcodes = [
Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [XcodeID(version: Version("12.0.0+1234A")!), XcodeID(version: Version("12.0.0-RC+1234A")!)], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil),
Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil),
Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil),
Xcode(version: Version("10.1.0")!, installState: .notInstalled, selected: false, icon: nil),
Xcode(version: Version("10.0.0")!, installState: .installed(Path("/Applications/Xcode-10.0.0.app")!), selected: false, icon: nil),
Xcode(version: Version("9.0.0")!, installState: .notInstalled, selected: false, icon: nil),
]
return a
}())
}
.previewLayout(.sizeThatFits)
}
}