diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index c1c5663..c7b7c7d 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63EAA4EA259944450046AB8F /* ProgressButton.swift */; }; CA11E7BA2598476C00D2EE1C /* XcodeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */; }; CA2518EC25A7FF2B00F08414 /* AppStateUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */; }; + CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25192925A9644800F08414 /* XcodeInstallState.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 */; }; @@ -149,6 +150,7 @@ 63EAA4EA259944450046AB8F /* ProgressButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressButton.swift; sourceTree = ""; }; CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeCommands.swift; sourceTree = ""; }; CA2518EB25A7FF2B00F08414 /* AppStateUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateUpdateTests.swift; sourceTree = ""; }; + CA25192925A9644800F08414 /* XcodeInstallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeInstallState.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 = ""; }; @@ -418,6 +420,7 @@ CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, CA61A6DF259835580008926E /* Xcode.swift */, + CA25192925A9644800F08414 /* XcodeInstallState.swift */, CA11E7B92598476C00D2EE1C /* XcodeCommands.swift */, ); path = Backend; @@ -739,6 +742,7 @@ CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, + CA25192A25A9644800F08414 /* XcodeInstallState.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 6ef2ff2..dc250e3 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -45,7 +45,7 @@ extension AppState { .handleEvents(receiveOutput: { installedXcode in DispatchQueue.main.async { guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return } - self.allXcodes[index].installState = .installed + self.allXcodes[index].installState = .installed(installedXcode.path) } }) .eraseToAnyPublisher() diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 8be93b1..d676bc3 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -385,15 +385,14 @@ class AppState: ObservableObject { }) // If the existing install state is "installing", keep it - let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installing }?.installState + let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installState.installing }?.installState // Otherwise, determine it from whether there's an installed Xcode - let defaultXcodeInstallState: XcodeInstallState = installedXcode != nil ? .installed : .notInstalled + let defaultXcodeInstallState: XcodeInstallState = installedXcode.map { .installed($0.path) } ?? .notInstalled return Xcode( version: availableXcode.version, installState: existingXcodeInstallState ?? defaultXcodeInstallState, selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, - path: installedXcode?.path.string, icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), requiredMacOSVersion: availableXcode.requiredMacOSVersion, releaseNotesURL: availableXcode.releaseNotesURL, @@ -410,9 +409,8 @@ class AppState: ObservableObject { newAllXcodes.append( Xcode( version: installedXcode.version, - installState: .installed, + installState: .installed(installedXcode.path), selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true, - path: installedXcode.path.string, icon: NSWorkspace.shared.icon(forFile: installedXcode.path.string) ) ) diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index 87cddcb..b662519 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -8,7 +8,6 @@ struct Xcode: Identifiable, CustomStringConvertible { let version: Version var installState: XcodeInstallState let selected: Bool - let path: String? let icon: NSImage? let requiredMacOSVersion: String? let releaseNotesURL: URL? @@ -19,7 +18,6 @@ struct Xcode: Identifiable, CustomStringConvertible { version: Version, installState: XcodeInstallState, selected: Bool, - path: String?, icon: NSImage?, requiredMacOSVersion: String? = nil, releaseNotesURL: URL? = nil, @@ -29,7 +27,6 @@ struct Xcode: Identifiable, CustomStringConvertible { self.version = version self.installState = installState self.selected = selected - self.path = path self.icon = icon self.requiredMacOSVersion = requiredMacOSVersion self.releaseNotesURL = releaseNotesURL @@ -38,21 +35,8 @@ struct Xcode: Identifiable, CustomStringConvertible { } var id: Version { version } - var installed: Bool { installState == .installed } - var installing: Bool { - switch installState { - case .installing: return true - default: return false - } - } var description: String { version.xcodeDescription } } - -enum XcodeInstallState: Equatable { - case notInstalled - case installing(InstallationStep) - case installed -} diff --git a/Xcodes/Backend/XcodeCommands.swift b/Xcodes/Backend/XcodeCommands.swift index b4a2a8c..7f32377 100644 --- a/Xcodes/Backend/XcodeCommands.swift +++ b/Xcodes/Backend/XcodeCommands.swift @@ -160,7 +160,7 @@ struct InstallCommand: View { @FocusedValue(\.selectedXcode) private var selectedXcode: SelectedXcode? var body: some View { - if selectedXcode.unwrapped?.installing == true { + if selectedXcode.unwrapped?.installState.installing == true { CancelInstallButton(xcode: selectedXcode.unwrapped) .keyboardShortcut(".", modifiers: [.command]) } else { @@ -178,7 +178,7 @@ struct SelectCommand: View { var body: some View { SelectButton(xcode: selectedXcode.unwrapped) .keyboardShortcut("s", modifiers: [.command, .option]) - .disabled(selectedXcode.unwrapped?.installed != true) + .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -189,7 +189,7 @@ struct OpenCommand: View { var body: some View { OpenButton(xcode: selectedXcode.unwrapped) .keyboardShortcut(KeyboardShortcut(.downArrow, modifiers: .command)) - .disabled(selectedXcode.unwrapped?.installed != true) + .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -200,7 +200,7 @@ struct RevealCommand: View { var body: some View { RevealButton(xcode: selectedXcode.unwrapped) .keyboardShortcut("r", modifiers: [.command, .option]) - .disabled(selectedXcode.unwrapped?.installed != true) + .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -211,7 +211,7 @@ struct CopyPathCommand: View { var body: some View { CopyPathButton(xcode: selectedXcode.unwrapped) .keyboardShortcut("c", modifiers: [.command, .option]) - .disabled(selectedXcode.unwrapped?.installed != true) + .disabled(selectedXcode.unwrapped?.installState.installed != true) } } @@ -222,6 +222,6 @@ struct UninstallCommand: View { var body: some View { UninstallButton(xcode: selectedXcode.unwrapped) .keyboardShortcut("u", modifiers: [.command, .option]) - .disabled(selectedXcode.unwrapped?.installed != true) + .disabled(selectedXcode.unwrapped?.installState.installed != true) } } diff --git a/Xcodes/Backend/XcodeInstallState.swift b/Xcodes/Backend/XcodeInstallState.swift new file mode 100644 index 0000000..a02181e --- /dev/null +++ b/Xcodes/Backend/XcodeInstallState.swift @@ -0,0 +1,27 @@ +import Foundation +import Path + +enum XcodeInstallState: Equatable { + case notInstalled + case installing(InstallationStep) + case installed(Path) + + var notInstalled: Bool { + switch self { + case .notInstalled: return true + default: return false + } + } + var installing: Bool { + switch self { + case .installing: return true + default: return false + } + } + var installed: Bool { + switch self { + case .installed: return true + default: return false + } + } +} diff --git a/Xcodes/Frontend/XcodeList/InfoPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift index d4b6916..bc40ca4 100644 --- a/Xcodes/Frontend/XcodeList/InfoPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -1,4 +1,5 @@ import AppKit +import Path import SwiftUI import Version import struct XCModel.SDKs @@ -25,28 +26,26 @@ struct InfoPane: View { InstallButton(xcode: xcode) case .installing: CancelInstallButton(xcode: xcode) - case .installed: - if let path = xcode.path { - HStack { - Text(path) - Button(action: { appState.reveal(id: xcode.id) }) { - Image(systemName: "arrow.right.circle.fill") - } - .buttonStyle(PlainButtonStyle()) - .help("Reveal in Finder") + case let .installed(path): + HStack { + Text(path.string) + Button(action: { appState.reveal(id: xcode.id) }) { + Image(systemName: "arrow.right.circle.fill") } + .buttonStyle(PlainButtonStyle()) + .help("Reveal in Finder") + } + + HStack { + SelectButton(xcode: xcode) + .disabled(xcode.selected) + .help("Selected") - HStack { - SelectButton(xcode: xcode) - .disabled(xcode.selected) - .help("Selected") - - OpenButton(xcode: xcode) - .help("Open") - - Spacer() - UninstallButton(xcode: xcode) - } + OpenButton(xcode: xcode) + .help("Open") + + Spacer() + UninstallButton(xcode: xcode) } } } @@ -70,8 +69,8 @@ struct InfoPane: View { @ViewBuilder private func icon(for xcode: Xcode) -> some View { - if let path = xcode.path { - Image(nsImage: NSWorkspace.shared.icon(forFile: path)) + if case let .installed(path) = xcode.installState { + Image(nsImage: NSWorkspace.shared.icon(forFile: path.string)) } else { Image(systemName: "app.fill") .resizable() @@ -183,9 +182,8 @@ struct InfoPane_Previews: PreviewProvider { $0.allXcodes = [ .init( version: Version(major: 12, minor: 3, patch: 0), - installState: .installed, + installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, - path: "/Applications/Xcode-12.3.0.app", icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"), requiredMacOSVersion: "10.15.4", releaseNotesURL: URL(string: "https://developer.apple.com/documentation/xcode-release-notes/xcode-12_3-release-notes/")!, @@ -211,9 +209,8 @@ struct InfoPane_Previews: PreviewProvider { $0.allXcodes = [ .init( version: Version(major: 12, minor: 3, patch: 0), - installState: .installed, + installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, - path: "/Applications/Xcode-12.3.0.app", icon: NSWorkspace.shared.icon(forFile: "/Applications/Xcode-12.3.0.app"), sdks: SDKs( macOS: .init(number: "11.1"), @@ -239,7 +236,6 @@ struct InfoPane_Previews: PreviewProvider { version: Version(major: 12, minor: 3, patch: 0), installState: .notInstalled, selected: false, - path: nil, icon: nil, sdks: SDKs( macOS: .init(number: "11.1"), @@ -263,9 +259,8 @@ struct InfoPane_Previews: PreviewProvider { $0.allXcodes = [ .init( version: Version(major: 12, minor: 3, patch: 0), - installState: .installed, + installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, - path: "/Applications/Xcode-12.3.0.app", icon: nil, sdks: nil, compilers: nil) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index b0fba45..f88d71c 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -1,3 +1,4 @@ +import Path import SwiftUI import Version @@ -19,7 +20,7 @@ struct XcodeListView: View { case .all: xcodes = appState.allXcodes case .installed: - xcodes = appState.allXcodes.filter { $0.installed } + xcodes = appState.allXcodes.filter { $0.installState.installed } } if !searchText.isEmpty { @@ -43,10 +44,10 @@ struct XcodeListView_Previews: PreviewProvider { .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ - Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), - Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, path: nil, icon: nil), - Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", 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), ] return a }()) diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index be213e5..5a3b4c1 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -1,3 +1,4 @@ +import Path import SwiftUI import Version @@ -14,9 +15,14 @@ struct XcodeListViewRow: View { Text(xcode.description) .font(.body) - Text(verbatim: xcode.path ?? "") - .font(.caption) - .foregroundColor(.secondary) + if case let .installed(path) = xcode.installState { + Text(verbatim: path.string) + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(verbatim: "") + .font(.caption) + } } Spacer() @@ -55,7 +61,7 @@ struct XcodeListViewRow: View { @ViewBuilder private func selectControl(for xcode: Xcode) -> some View { - if xcode.installed { + if xcode.installState.installed { if xcode.selected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) @@ -98,22 +104,22 @@ struct XcodeListViewRow_Previews: PreviewProvider { static var previews: some View { Group { XcodeListViewRow( - xcode: Xcode(version: Version("12.3.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-12.3.0.app", icon: nil), + xcode: Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil), selected: false ) XcodeListViewRow( - xcode: Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), + xcode: Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil), selected: false ) XcodeListViewRow( - xcode: Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, path: nil, icon: nil), + xcode: Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil), selected: false ) XcodeListViewRow( - xcode: Xcode(version: Version("12.0.0")!, installState: .installed, selected: false, path: "/Applications/Xcode-12.3.0.app", icon: nil), + xcode: Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil), selected: false ) } diff --git a/XcodesTests/AppStateTests.swift b/XcodesTests/AppStateTests.swift index ff9598e..b5f7dc3 100644 --- a/XcodesTests/AppStateTests.swift +++ b/XcodesTests/AppStateTests.swift @@ -67,9 +67,9 @@ class AppStateTests: XCTestCase { func test_Install_FullHappyPath_Apple() throws { // Available xcode doesn't necessarily have build identifier subject.allXcodes = [ - .init(version: Version("0.0.0")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - .init(version: Version("0.0.0-Beta.1")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - .init(version: Version("0.0.0-Beta.2")!, installState: .notInstalled, selected: false, path: nil, icon: nil) + .init(version: Version("0.0.0")!, installState: .notInstalled, selected: false, icon: nil), + .init(version: Version("0.0.0-Beta.1")!, installState: .notInstalled, selected: false, icon: nil), + .init(version: Version("0.0.0-Beta.2")!, installState: .notInstalled, selected: false, icon: nil), ] // It hasn't been downloaded @@ -175,9 +175,9 @@ class AppStateTests: XCTestCase { func test_Install_FullHappyPath_XcodeReleases() throws { // Available xcode has build identifier subject.allXcodes = [ - .init(version: Version("0.0.0+ABC123")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - .init(version: Version("0.0.0-Beta.1+DEF456")!, installState: .notInstalled, selected: false, path: nil, icon: nil), - .init(version: Version("0.0.0-Beta.2+GHI789")!, installState: .notInstalled, selected: false, path: nil, icon: nil) + .init(version: Version("0.0.0+ABC123")!, installState: .notInstalled, selected: false, icon: nil), + .init(version: Version("0.0.0-Beta.1+DEF456")!, installState: .notInstalled, selected: false, icon: nil), + .init(version: Version("0.0.0-Beta.2+GHI789")!, installState: .notInstalled, selected: false, icon: nil) ] // It hasn't been downloaded diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index b0c668c..997108e 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -13,7 +13,7 @@ class AppStateUpdateTests: XCTestCase { func testDoesNotReplaceInstallState() throws { subject.allXcodes = [ - Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, path: nil, icon: nil) + Xcode(version: Version("0.0.0")!, installState: .installing(.unarchiving), selected: false, icon: nil) ] subject.updateAllXcodes( @@ -30,7 +30,7 @@ class AppStateUpdateTests: XCTestCase { func testRemovesUninstalledVersion() throws { subject.allXcodes = [ - Xcode(version: Version("0.0.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-0.0.0.app", icon: NSImage(systemSymbolName: "app.fill", accessibilityDescription: nil)) + Xcode(version: Version("0.0.0")!, installState: .installed(Path("/Applications/Xcode-0.0.0.app")!), selected: true, icon: NSImage(systemSymbolName: "app.fill", accessibilityDescription: nil)) ] subject.updateAllXcodes( @@ -61,9 +61,8 @@ class AppStateUpdateTests: XCTestCase { ) XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) - XCTAssertEqual(subject.allXcodes[0].installState, .installed) + XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) - XCTAssertEqual(subject.allXcodes[0].path, "/Applications/Xcode-0.0.0.app") } func testAdjustedVersionsAreUsedToLookupAvailableXcode() throws { @@ -82,9 +81,8 @@ class AppStateUpdateTests: XCTestCase { ) XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!) - XCTAssertEqual(subject.allXcodes[0].installState, .installed) + XCTAssertEqual(subject.allXcodes[0].installState, .installed(Path("/Applications/Xcode-0.0.0.app")!)) XCTAssertEqual(subject.allXcodes[0].selected, false) - XCTAssertEqual(subject.allXcodes[0].path, "/Applications/Xcode-0.0.0.app") // XCModel types aren't equatable, so just check for non-nil for now XCTAssertNotNil(subject.allXcodes[0].sdks) }