diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 579d372..6def8ab 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -8,10 +8,12 @@ /* Begin PBXBuildFile section */ 63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63EAA4EA259944450046AB8F /* ProgressButton.swift */; }; + CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; }; CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; }; CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; }; CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; }; + CA61A6E0259835580008926E /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA61A6DF259835580008926E /* Xcode.swift */; }; CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA735108257BF96D00EA9CF8 /* AttributedText.swift */; }; CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */; }; CA9FF83F2594FBC000E47BAF /* Licenses.rtf in Resources */ = {isa = PBXBuildFile; fileRef = CA9FF83E2594FBC000E47BAF /* Licenses.rtf */; }; @@ -58,6 +60,8 @@ CAD2E7A62449575000113D76 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A52449575000113D76 /* Assets.xcassets */; }; CAD2E7A92449575000113D76 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CAD2E7A82449575000113D76 /* Preview Assets.xcassets */; }; CAD2E7B82449575100113D76 /* XcodesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAD2E7B72449575100113D76 /* XcodesTests.swift */; }; + CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */; }; + CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,11 +76,13 @@ /* Begin PBXFileReference section */ 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; + CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = ""; }; CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = ""; }; CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = ""; }; + CA61A6DF259835580008926E /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = ""; }; CA735108257BF96D00EA9CF8 /* AttributedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedText.swift; sourceTree = ""; }; CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+.swift"; sourceTree = ""; }; CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -127,6 +133,8 @@ CAD2E7B32449575100113D76 /* XcodesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XcodesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CAD2E7B72449575100113D76 /* XcodesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodesTests.swift; sourceTree = ""; }; CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = ""; }; + CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -219,16 +227,20 @@ CABFA9B22592EEEA00380FEE /* Entry+.swift */, CABFA9A92592EEE900380FEE /* Environment.swift */, CABFA9B82592EEEA00380FEE /* FileManager+.swift */, + CAFBDB942598FE96003DCC5A /* FocusedValues.swift */, CABFA9AC2592EEE900380FEE /* Foundation.swift */, CA9FF8862595607900E47BAF /* InstalledXcode.swift */, CABFA9AE2592EEE900380FEE /* Path+.swift */, CABFA9B42592EEEA00380FEE /* Process.swift */, CABFA9B02592EEEA00380FEE /* Promise+.swift */, + CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */, CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */, CABFA9A82592EEE900380FEE /* Version+.swift */, CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, + CA61A6DF259835580008926E /* Xcode.swift */, + CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */, ); path = Backend; sourceTree = ""; @@ -459,6 +471,7 @@ buildActionMask = 2147483647; files = ( CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, + CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */, @@ -473,15 +486,18 @@ CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */, CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */, CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */, + CA61A6E0259835580008926E /* Xcode.swift in Sources */, CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */, CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */, CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */, CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, + CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, + CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index e0ec6e2..ffa4516 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -5,6 +5,7 @@ import Path import LegibleError import KeychainAccess import SwiftUI +import Version class AppState: ObservableObject { private let client = AppleAPI.Client() @@ -13,10 +14,10 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var availableXcodes: [AvailableXcode] = [] { willSet { - updateAllVersions(newValue) + updateAllXcodes(newValue) } } - var allVersions: [XcodeVersion] = [] + var allXcodes: [Xcode] = [] @Published var updatePublisher: AnyCancellable? var isUpdating: Bool { updatePublisher != nil } @Published var error: AlertContent? @@ -24,6 +25,11 @@ class AppState: ObservableObject { @Published var presentingSignInAlert = false @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? + // Selected in the Xcode list, not in the xcode-select sense + // This probably belongs as private @State in XcodeListView, + // but we need it here instead so that it can be a focusedValue at the top level in XcodesApp instead of in a list row. The latter seems more like how the focusedValue API is supposed to work, but currently doesn't. + @Published var selectedXcodeID: Xcode.ID? + @Published var xcodeBeingConfirmedForUninstallation: Xcode? init() { try? loadCachedAvailableXcodes() @@ -171,27 +177,39 @@ class AppState: ObservableObject { // MARK: - - func install(id: String) { + func install(id: Xcode.ID) { // TODO: } - func uninstall(id: String) { + func uninstall(id: Xcode.ID) { // TODO: } - func reveal(id: String) { + func reveal(id: Xcode.ID) { // TODO: show error if not - guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return } + guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return } NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url]) } - func select(id: String) { + func select(id: Xcode.ID) { // TODO: } + func launch(id: Xcode.ID) { + guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return } + NSWorkspace.shared.openApplication(at: installedXcode.path.url, configuration: .init()) + } + + func copyPath(id: Xcode.ID) { + guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version == id }) else { return } + NSPasteboard.general.declareTypes([.URL, .string], owner: nil) + NSPasteboard.general.writeObjects([installedXcode.path.url as NSURL]) + NSPasteboard.general.setString(installedXcode.path.string, forType: .string) + } + // MARK: - Private - private func updateAllVersions(_ xcodes: [AvailableXcode]) { + private func updateAllXcodes(_ xcodes: [AvailableXcode]) { let installedXcodes = Current.files.installedXcodes(Path.root/"Applications") var allXcodeVersions = xcodes.map { $0.version } for installedXcode in installedXcodes { @@ -210,37 +228,22 @@ class AppState: ObservableObject { } } - allVersions = allXcodeVersions + allXcodes = allXcodeVersions .sorted(by: >) .map { xcodeVersion in let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) - return XcodeVersion( - title: xcodeVersion.xcodeDescription, + return Xcode( + version: xcodeVersion, installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, selected: false, - path: installedXcode?.path.string + path: installedXcode?.path.string, + icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)) ) } } // MARK: - Nested Types - - /// A merging of AvailableXcode and InstalledXcode prepared for display - struct XcodeVersion: Identifiable { - let title: String - let installState: InstallState - let selected: Bool - let path: String? - var id: String { title } - var installed: Bool { installState == .installed } - } - - enum InstallState: Equatable { - case notInstalled - case installing(Progress) - case installed - } struct AlertContent: Identifiable { var title: String diff --git a/Xcodes/Backend/FocusedValues.swift b/Xcodes/Backend/FocusedValues.swift new file mode 100644 index 0000000..f841021 --- /dev/null +++ b/Xcodes/Backend/FocusedValues.swift @@ -0,0 +1,16 @@ +import SwiftUI + +// MARK: - FocusedXcodeKey + +struct FocusedXcodeKey : FocusedValueKey { + typealias Value = SelectedXcode +} + +// MARK: - FocusedValues + +extension FocusedValues { + var selectedXcode: FocusedXcodeKey.Value? { + get { self[FocusedXcodeKey.self] } + set { self[FocusedXcodeKey.self] = newValue } + } +} diff --git a/Xcodes/Backend/SelectedXcode.swift b/Xcodes/Backend/SelectedXcode.swift new file mode 100644 index 0000000..30b12a1 --- /dev/null +++ b/Xcodes/Backend/SelectedXcode.swift @@ -0,0 +1,35 @@ +import Foundation + +/// As part of the unexpected way we have to use focusedValue in XcodesApp, we need to provide an `Optional` because there isn't always a selected Xcode in the focused window. +/// But FocusedValueKey.Value is already optional, because there might not be a focused UI element to begin with, so the type ends up being `Optional>`. +/// This is weird enough, but I wasn't able to find a way to have FocusedXcodeKey.Value be `Optional>` and still compile. +/// There was always an error somewhere in either the use of @FocusedValue or FocusedValues.xcode or .focusedValue, as if it is only ever expecting a single level of optionality. +/// But! If we make our own Optional replica like SelectedXcode, it _does_ compile, and there's some more noise required to turn it back into an `Optional`. +/// All this to say, maybe one day we don't need to have this type at all. +enum SelectedXcode { + case none + case some(Xcode) + + init(_ optional: Optional) { + switch optional { + case .none: self = .none + case let .some(xcode): self = .some(xcode) + } + } + + var asOptional: Xcode? { + switch self { + case .none: return .none + case let .some(xcode): return .some(xcode) + } + } +} + +extension Optional where Wrapped == SelectedXcode { + var unwrapped: Xcode? { + switch self { + case Optional.none: return Optional.none + case let .some(selectedXcode): return selectedXcode.asOptional + } + } +} diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift new file mode 100644 index 0000000..c322288 --- /dev/null +++ b/Xcodes/Backend/Xcode.swift @@ -0,0 +1,24 @@ +import AppKit +import Foundation +import Version + +struct Xcode: Identifiable, CustomStringConvertible { + let version: Version + let installState: XcodeInstallState + let selected: Bool + let path: String? + let icon: NSImage? + + var id: Version { version } + var installed: Bool { installState == .installed } + + var description: String { + version.xcodeDescription + } +} + +enum XcodeInstallState: Equatable { + case notInstalled + case installing(Progress) + case installed +} diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift new file mode 100644 index 0000000..c8fd256 --- /dev/null +++ b/Xcodes/Backend/XcodeCommands.swift @@ -0,0 +1,172 @@ +import SwiftUI + +// MARK: - CommandMenu + +struct XcodeCommands: Commands { + // CommandMenus don't participate in the environment hierarchy, so we need to shuffle AppState along to the individual Commands manually. + let appState: AppState + + var body: some Commands { + CommandMenu("Xcode") { + Group { + InstallCommand() + + Divider() + + SelectCommand() + LaunchCommand() + RevealCommand() + CopyPathCommand() + } + .environmentObject(appState) + } + } +} + +// MARK: - Buttons +// These are used for both context menus and commands + +struct InstallButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + var body: some View { + Button(action: uninstallOrInstall) { + if let xcode = xcode { + Text(xcode.installed == true ? "Uninstall" : "Install") + } else { + Text("Install") + } + } + } + + private func uninstallOrInstall() { + guard let xcode = xcode else { return } + if xcode.installed { + appState.xcodeBeingConfirmedForUninstallation = xcode + } else { + appState.install(id: xcode.id) + } + } +} + +struct SelectButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + var body: some View { + Button(action: select) { + Text("Select") + } + } + + private func select() { + guard let xcode = xcode else { return } + appState.select(id: xcode.id) + } +} + +struct LaunchButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + var body: some View { + Button(action: launch) { + Text("Launch") + } + } + + private func launch() { + guard let xcode = xcode else { return } + appState.launch(id: xcode.id) + } +} + +struct RevealButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + var body: some View { + Button(action: reveal) { + Text("Reveal in Finder") + } + } + + private func reveal() { + guard let xcode = xcode else { return } + appState.reveal(id: xcode.id) + } +} + +struct CopyPathButton: View { + @EnvironmentObject var appState: AppState + let xcode: Xcode? + + var body: some View { + Button(action: copyPath) { + Text("Copy Path") + } + } + + private func copyPath() { + guard let xcode = xcode else { return } + appState.copyPath(id: xcode.id) + } +} + +// MARK: - Commands + +struct InstallCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + InstallButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut(selectedXcode.unwrapped?.installed == true ? "u" : "i", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped == nil) + } +} + +struct SelectCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + SelectButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut("s", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped?.installed != true) + } +} + +struct LaunchCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + LaunchButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut(KeyboardShortcut(.downArrow, modifiers: .command)) + .disabled(selectedXcode.unwrapped?.installed != true) + } +} + +struct RevealCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + RevealButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut("r", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped?.installed != true) + } +} + +struct CopyPathCommand: View { + @EnvironmentObject var appState: AppState + @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? + + var body: some View { + CopyPathButton(xcode: selectedXcode.unwrapped) + .keyboardShortcut("c", modifiers: [.command, .option]) + .disabled(selectedXcode.unwrapped?.installed != true) + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index c97b3b6..d10eb8a 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -4,27 +4,25 @@ import PromiseKit struct XcodeListView: View { @EnvironmentObject var appState: AppState - @State private var selection = Set() - @State private var rowBeingConfirmedForUninstallation: AppState.XcodeVersion? + @State private var selection: Xcode.ID? @State private var searchText: String = "" @AppStorage("lastUpdated") private var lastUpdated: Double? - @AppStorage("xcodeListCategory") private var category: Category = .all - var visibleVersions: [AppState.XcodeVersion] { - var versions: [AppState.XcodeVersion] + var visibleXcodes: [Xcode] { + var xcodes: [Xcode] switch category { case .all: - versions = appState.allVersions + xcodes = appState.allXcodes case .installed: - versions = appState.allVersions.filter { $0.installed } + xcodes = appState.allXcodes.filter { $0.installed } } if !searchText.isEmpty { - versions = versions.filter { $0.title.contains(searchText) } + xcodes = xcodes.filter { $0.description.contains(searchText) } } - return versions + return xcodes } enum Category: String, CaseIterable, Identifiable, CustomStringConvertible { @@ -42,38 +40,43 @@ struct XcodeListView: View { } var body: some View { - List(visibleVersions, selection: $selection) { row in - VStack(alignment: .leading) { - HStack { - Text(row.title) + List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in + HStack { + appIconView(for: xcode) + + VStack(alignment: .leading) { + Text(xcode.description) .font(.body) - if row.selected { - Tag(text: "SELECTED") - .foregroundColor(.green) - } - Spacer() - Button(row.installed ? "INSTALLED" : "INSTALL") { - print("Installing...") - } - .buttonStyle(AppStoreButtonStyle(installed: row.installed, - highlighted: self.selection.contains(row.id))) - .disabled(row.installed) + + Text(verbatim: xcode.path ?? "") + .font(.caption) + .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) } - Text(verbatim: row.path ?? "") - .font(.caption) - .foregroundColor(self.selection.contains(row.id) ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) + + if xcode.selected { + Tag(text: "SELECTED") + .foregroundColor(.green) + } + + Spacer() + + Button(xcode.installed ? "INSTALLED" : "INSTALL") { + print("Installing...") + } + .buttonStyle(AppStoreButtonStyle(installed: xcode.installed, + highlighted: appState.selectedXcodeID == xcode.id)) + .disabled(xcode.installed) } .contextMenu { - Button(action: { row.installed ? self.rowBeingConfirmedForUninstallation = row : self.appState.install(id: row.id) }) { - Text(row.installed ? "Uninstall" : "Install") - } - if row.installed { - Button(action: { self.appState.reveal(id: row.id) }) { - Text("Reveal in Finder") - } - Button(action: { self.appState.select(id: row.id) }) { - Text("Select") - } + InstallButton(xcode: xcode) + + Divider() + + if xcode.installed { + SelectButton(xcode: xcode) + LaunchButton(xcode: xcode) + RevealButton(xcode: xcode) + CopyPathButton(xcode: xcode) } } } @@ -114,10 +117,10 @@ struct XcodeListView: View { } /* Removing this for now, because it's overriding the error alert that's being worked on above. - .alert(item: self.$rowBeingConfirmedForUninstallation) { row in - Alert(title: Text("Uninstall Xcode \(row.title)?"), + .alert(item: $appState.xcodeBeingConfirmedForUninstallation) { xcode in + Alert(title: Text("Uninstall Xcode \(xcode.description)?"), message: Text("It will be moved to the Trash, but won't be emptied."), - primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: row.id) }), + primaryButton: .destructive(Text("Uninstall"), action: { self.appState.uninstall(id: xcode.id) }), secondaryButton: .cancel(Text("Cancel"))) } **/ @@ -146,6 +149,17 @@ struct XcodeListView: View { return Text("") } } + + @ViewBuilder + func appIconView(for xcode: Xcode) -> some View { + if let icon = xcode.icon { + Image(nsImage: icon) + } else { + Color.clear + .frame(width: 32, height: 32) + .foregroundColor(.secondary) + } + } } struct XcodeListView_Previews: PreviewProvider { @@ -154,11 +168,11 @@ struct XcodeListView_Previews: PreviewProvider { XcodeListView() .environmentObject({ () -> AppState in let a = AppState() - a.allVersions = [ - AppState.XcodeVersion(title: "12.3", installState: .installed, selected: true, path: nil), - AppState.XcodeVersion(title: "12.2", installState: .notInstalled, selected: false, path: nil), - AppState.XcodeVersion(title: "12.1", installState: .notInstalled, selected: false, path: nil), - AppState.XcodeVersion(title: "12.0", installState: .installed, selected: false, path: nil), + a.allXcodes = [ + Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: nil, icon: nil), + Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + Xcode(version: Version("12.1.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: nil, icon: nil), ] return a }()) diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index ea66958..14036a1 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -20,6 +20,9 @@ struct XcodesApp: App { appState.updateIfNeeded() } } + // I'm expecting to be able to use this modifier on a List row, but using it at the top level here is the only way that has made XcodeCommands work so far. + // FB8954571 focusedValue(_:_:) on List row doesn't propagate value to @FocusedValue + .focusedValue(\.selectedXcode, SelectedXcode(appState.allXcodes.first { $0.id == appState.selectedXcodeID })) } .commands { CommandGroup(replacing: .appInfo) { @@ -34,6 +37,8 @@ struct XcodesApp: App { .keyboardShortcut(KeyEquivalent("r")) .disabled(appState.isUpdating) } + + XcodeCommands(appState: appState) } Settings {