diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index a17674d..6def8ab 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* 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 */; }; @@ -59,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 */ @@ -73,6 +76,7 @@ /* 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 = ""; }; @@ -129,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 */ @@ -221,17 +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 = ""; @@ -462,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 */, @@ -481,11 +491,13 @@ 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 34a0113..1b68ea0 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -25,6 +25,10 @@ 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() { 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/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 24dc9df..4a1f99f 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -40,7 +40,7 @@ struct XcodeListView: View { } var body: some View { - List(visibleXcodes, selection: $selection) { xcode in + List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in VStack(alignment: .leading) { HStack { Text(xcode.description) @@ -54,31 +54,23 @@ struct XcodeListView: View { print("Installing...") } .buttonStyle(AppStoreButtonStyle(installed: xcode.installed, - highlighted: selection == xcode.id)) + highlighted: appState.selectedXcodeID == xcode.id)) .disabled(xcode.installed) } Text(verbatim: xcode.path ?? "") .font(.caption) - .foregroundColor(selection == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) + .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) } .contextMenu { - Button(action: { xcode.installed ? appState.xcodeBeingConfirmedForUninstallation = xcode : self.appState.install(id: xcode.id) }) { - Text(xcode.installed ? "Uninstall" : "Install") - } + InstallButton(xcode: xcode) + Divider() + if xcode.installed { - Button(action: { self.appState.select(id: xcode.id) }) { - Text("Select") - } - Button(action: { self.appState.launch(id: xcode.id) }) { - Text("Launch") - } - Button(action: { self.appState.reveal(id: xcode.id) }) { - Text("Reveal in Finder") - } - Button(action: { self.appState.copyPath(id: xcode.id) }) { - Text("Copy Path") - } + SelectButton(xcode: xcode) + LaunchButton(xcode: xcode) + RevealButton(xcode: xcode) + CopyPathButton(xcode: xcode) } } } 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 {