Add Xcode command menu

This commit is contained in:
Brandon Evans 2020-12-26 21:51:59 -07:00
parent 912ac0a28e
commit 4d2600f821
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
7 changed files with 254 additions and 18 deletions

View file

@ -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 = "<group>"; };
CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = "<group>"; };
CA378F982466567600A58CE0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreButtonStyle.swift; sourceTree = "<group>"; };
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
@ -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 = "<group>"; };
CAD2E7B92449575100113D76 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedXcode.swift; sourceTree = "<group>"; };
CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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 */,

View file

@ -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() {

View file

@ -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 }
}
}

View file

@ -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<Xcode>` 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<Optional<Xcode>>`.
/// This is weird enough, but I wasn't able to find a way to have FocusedXcodeKey.Value be `Optional<Optional<Xcode>>` 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<Xcode>`.
/// 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<Xcode>) {
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<SelectedXcode>.none: return Optional<Xcode>.none
case let .some(selectedXcode): return selectedXcode.asOptional
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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 {