From ba0c429766d565d21ae9f112703a7aa6117b4720 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Mon, 28 Dec 2020 09:14:21 -0700 Subject: [PATCH] Add trailing inspector pane --- Xcodes.xcodeproj/project.pbxproj | 8 + Xcodes/Backend/AppState+Update.swift | 6 +- Xcodes/Backend/AppState.swift | 7 +- Xcodes/Backend/AvailableXcode.swift | 21 +- Xcodes/Backend/Xcode.swift | 28 ++ Xcodes/Frontend/View+Conditional.swift | 12 + Xcodes/Frontend/XcodeList/InspectorPane.swift | 265 ++++++++++++++++++ Xcodes/Frontend/XcodeList/MainToolbar.swift | 22 +- Xcodes/Frontend/XcodeList/XcodeListView.swift | 88 +++--- Xcodes/XcodesApp.swift | 1 + 10 files changed, 416 insertions(+), 42 deletions(-) create mode 100644 Xcodes/Frontend/View+Conditional.swift create mode 100644 Xcodes/Frontend/XcodeList/InspectorPane.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 5852ae2..d5f52f0 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -72,6 +72,8 @@ CAFBDB912598FE80003DCC5A /* SelectedXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB902598FE80003DCC5A /* SelectedXcode.swift */; }; CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDB942598FE96003DCC5A /* FocusedValues.swift */; }; CAFBDC4E2599B33D003DCC5A /* MainToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */; }; + CAFBDC68259A308B003DCC5A /* InspectorPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC67259A308B003DCC5A /* InspectorPane.swift */; }; + CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -191,6 +193,8 @@ CAFBDB942598FE96003DCC5A /* FocusedValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedValues.swift; sourceTree = ""; }; CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = ""; }; CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainToolbar.swift; sourceTree = ""; }; + CAFBDC67259A308B003DCC5A /* InspectorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorPane.swift; sourceTree = ""; }; + CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -295,6 +299,7 @@ isa = PBXGroup; children = ( CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */, + CAFBDC67259A308B003DCC5A /* InspectorPane.swift */, CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */, CA44901E2463AD34003D8213 /* Tag.swift */, CAD2E7A32449574E00113D76 /* XcodeListView.swift */, @@ -344,6 +349,7 @@ CAA1CB50255A5D16003FD669 /* SignIn */, CABFAA142592F73000380FEE /* XcodeList */, CABFAA2A2592FBFC00380FEE /* SettingsView.swift */, + CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */, CA9FF8652595130600E47BAF /* View+IsHidden.swift */, ); path = Frontend; @@ -633,7 +639,9 @@ CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CAFBDB952598FE96003DCC5A /* FocusedValues.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, + CAFBDC68259A308B003DCC5A /* InspectorPane.swift in Sources */, CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */, + CAFBDC6C259A3098003DCC5A /* View+Conditional.swift in Sources */, CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 856d49f..eb5d977 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -170,7 +170,11 @@ extension AppState { version: version, url: downloadURL, filename: String(downloadURL.path.suffix(fromLast: "/")), - releaseDate: releaseDate + releaseDate: releaseDate, + requiredMacOSVersion: xcReleasesXcode.requires, + releaseNotesURL: xcReleasesXcode.links?.notes?.url, + sdks: xcReleasesXcode.sdks, + compilers: xcReleasesXcode.compilers ) } return xcodes diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1fd3c5c..48a8c1c 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -274,12 +274,17 @@ class AppState: ObservableObject { .sorted(by: >) .map { xcodeVersion in let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) + let availableXcode = xcodes.first { $0.version == xcodeVersion } return Xcode( version: xcodeVersion, installState: installedXcodes.contains(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) }) ? .installed : .notInstalled, selected: false, path: installedXcode?.path.string, - icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)) + icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), + requiredMacOSVersion: availableXcode?.requiredMacOSVersion, + releaseNotesURL: availableXcode?.releaseNotesURL, + sdks: availableXcode?.sdks, + compilers: availableXcode?.compilers ) } } diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/Backend/AvailableXcode.swift index f1c0c0a..8ef4e18 100644 --- a/Xcodes/Backend/AvailableXcode.swift +++ b/Xcodes/Backend/AvailableXcode.swift @@ -1,5 +1,7 @@ import Foundation import Version +import struct XCModel.SDKs +import struct XCModel.Compilers /// A version of Xcode that's available for installation public struct AvailableXcode: Codable { @@ -7,11 +9,28 @@ public struct AvailableXcode: Codable { public let url: URL public let filename: String public let releaseDate: Date? + public let requiredMacOSVersion: String? + public let releaseNotesURL: URL? + public let sdks: SDKs? + public let compilers: Compilers? - public init(version: Version, url: URL, filename: String, releaseDate: Date?) { + public init( + version: Version, + url: URL, + filename: String, + releaseDate: Date?, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil + ) { self.version = version self.url = url self.filename = filename self.releaseDate = releaseDate + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.sdks = sdks + self.compilers = compilers } } diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index c322288..cfb8d7d 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -1,6 +1,8 @@ import AppKit import Foundation import Version +import struct XCModel.SDKs +import struct XCModel.Compilers struct Xcode: Identifiable, CustomStringConvertible { let version: Version @@ -8,6 +10,32 @@ struct Xcode: Identifiable, CustomStringConvertible { let selected: Bool let path: String? let icon: NSImage? + let requiredMacOSVersion: String? + let releaseNotesURL: URL? + let sdks: SDKs? + let compilers: Compilers? + + init( + version: Version, + installState: XcodeInstallState, + selected: Bool, + path: String?, + icon: NSImage?, + requiredMacOSVersion: String? = nil, + releaseNotesURL: URL? = nil, + sdks: SDKs? = nil, + compilers: Compilers? = nil + ) { + self.version = version + self.installState = installState + self.selected = selected + self.path = path + self.icon = icon + self.requiredMacOSVersion = requiredMacOSVersion + self.releaseNotesURL = releaseNotesURL + self.sdks = sdks + self.compilers = compilers + } var id: Version { version } var installed: Bool { installState == .installed } diff --git a/Xcodes/Frontend/View+Conditional.swift b/Xcodes/Frontend/View+Conditional.swift new file mode 100644 index 0000000..543d993 --- /dev/null +++ b/Xcodes/Frontend/View+Conditional.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + @ViewBuilder + func `if`(_ predicate: Bool, then: (Self) -> Other) -> some View { + if predicate { + then(self) + } else { + self + } + } +} diff --git a/Xcodes/Frontend/XcodeList/InspectorPane.swift b/Xcodes/Frontend/XcodeList/InspectorPane.swift new file mode 100644 index 0000000..5e51b07 --- /dev/null +++ b/Xcodes/Frontend/XcodeList/InspectorPane.swift @@ -0,0 +1,265 @@ +import AppKit +import SwiftUI +import Version +import struct XCModel.SDKs +import struct XCModel.Compilers + +struct InspectorPane: View { + @EnvironmentObject var appState: AppState + @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction + + var body: some View { + Group { + if let xcode = appState.allXcodes.first(where: { $0.id == appState.selectedXcodeID }) { + VStack(spacing: 16) { + icon(for: xcode) + + VStack(alignment: .leading) { + Text("Xcode \(xcode.description)") + .font(.title) + .frame(maxWidth: .infinity, alignment: .leading) + + if let path = xcode.path { + HStack { + Text(path) + Button(action: { appState.reveal(id: xcode.id) }) { + Image(systemName: "arrow.right.circle.fill") + } + .buttonStyle(PlainButtonStyle()) + } + + HStack { + if xcode.selected { + Button("Selected", action: {}) + .disabled(true) + } else { + SelectButton(xcode: xcode) + } + + LaunchButton(xcode: xcode) + } + } else { + InstallButton(xcode: xcode) + } + } + + Divider() + + releaseNotes(for: xcode) + compatibility(for: xcode) + sdks(for: xcode) + compilers(for: xcode) + + Spacer() + } + } else { + empty + } + } + .padding() + .frame(minWidth: 200, maxWidth: .infinity) + } + + @ViewBuilder + private func icon(for xcode: Xcode) -> some View { + if let path = xcode.path { + Image(nsImage: NSWorkspace.shared.icon(forFile: path)) + } else { + Image(systemName: "app.fill") + .resizable() + .frame(width: 32, height: 32) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func releaseNotes(for xcode: Xcode) -> some View { + if let releaseNotesURL = xcode.releaseNotesURL { + Button(action: { openURL(releaseNotesURL) }) { + Label("Release Notes", systemImage: "link") + } + .buttonStyle(LinkButtonStyle()) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + EmptyView() + } + } + + @ViewBuilder + private func compatibility(for xcode: Xcode) -> some View { + if let requiredMacOSVersion = xcode.requiredMacOSVersion { + VStack(alignment: .leading) { + Text("Compatibility") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + Text("Requires macOS \(requiredMacOSVersion) or later") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func sdks(for xcode: Xcode) -> some View { + if let sdks = xcode.sdks { + VStack(alignment: .leading) { + Text("SDKs") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach([ + ("macOS", \SDKs.macOS), + ("iOS", \.iOS), + ("watchOS", \.watchOS), + ("tvOS", \.tvOS), + ], id: \.0) { row in + if let sdk = sdks[keyPath: row.1] { + Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + private func compilers(for xcode: Xcode) -> some View { + if let compilers = xcode.compilers { + VStack(alignment: .leading) { + Text("Compilers") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach([ + ("Swift", \Compilers.swift), + ("Clang", \.clang), + ("LLVM", \.llvm), + ("LLVM GCC", \.llvm_gcc), + ("GCC", \.gcc), + ], id: \.0) { row in + if let sdk = compilers[keyPath: row.1] { + Text("\(row.0): \(sdk.compactMap { $0.number }.joined(separator: ", "))") + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } else { + EmptyView() + } + } + + @ViewBuilder + private var empty: some View { + VStack { + Spacer() + Text("No Xcode Selected") + .font(.title) + .foregroundColor(.secondary) + Spacer() + } + } +} + +struct InspectorPane_Previews: PreviewProvider { + static var previews: some View { + Group { + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .installed, + 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/")!, + sdks: SDKs( + macOS: .init(number: "11.1"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Installed, Selected") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .installed, + 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"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Installed, Unselected") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + .init( + version: Version(major: 12, minor: 3, patch: 0), + installState: .notInstalled, + selected: false, + path: nil, + icon: nil, + sdks: SDKs( + macOS: .init(number: "11.1"), + iOS: .init(number: "14.3"), + watchOS: .init(number: "7.3"), + tvOS: .init(number: "14.3") + ), + compilers: Compilers( + gcc: .init(number: "4"), + llvm_gcc: .init(number: "213"), + llvm: .init(number: "2.3"), + clang: .init(number: "7.3"), + swift: .init(number: "5.3.2") + )) + ] + $0.selectedXcodeID = Version(major: 12, minor: 3, patch: 0) + }) + .previewDisplayName("Populated, Uninstalled") + + InspectorPane() + .environmentObject(configure(AppState()) { + $0.allXcodes = [ + ] + $0.selectedXcodeID = nil + }) + .previewDisplayName("Empty") + } + .frame(maxWidth: 300) + } +} diff --git a/Xcodes/Frontend/XcodeList/MainToolbar.swift b/Xcodes/Frontend/XcodeList/MainToolbar.swift index 1c60822..42594a4 100644 --- a/Xcodes/Frontend/XcodeList/MainToolbar.swift +++ b/Xcodes/Frontend/XcodeList/MainToolbar.swift @@ -3,6 +3,7 @@ import SwiftUI struct MainToolbarModifier: ViewModifier { @EnvironmentObject var appState: AppState @Binding var category: XcodeListView.Category + @Binding var isShowingInfoPane: Bool @Binding var searchText: String func body(content: Content) -> some View { @@ -35,6 +36,16 @@ struct MainToolbarModifier: ViewModifier { } } + Button(action: { isShowingInfoPane.toggle() }) { + if isShowingInfoPane { + Label("Inspector", systemImage: "info.circle.fill") + .foregroundColor(.accentColor) + } else { + Label("Inspector", systemImage: "info.circle") + } + } + .keyboardShortcut(KeyboardShortcut("i", modifiers: [.command, .option])) + TextField("Search...", text: $searchText) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 200) @@ -44,9 +55,16 @@ struct MainToolbarModifier: ViewModifier { extension View { func mainToolbar( - category: Binding, + category: Binding, + isShowingInfoPane: Binding, searchText: Binding ) -> some View { - self.modifier(MainToolbarModifier(category: category, searchText: searchText)) + self.modifier( + MainToolbarModifier( + category: category, + isShowingInfoPane: isShowingInfoPane, + searchText: searchText + ) + ) } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index d58b9fb..2492b73 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -7,7 +7,8 @@ struct XcodeListView: View { @State private var selection: Xcode.ID? @State private var searchText: String = "" @AppStorage("lastUpdated") private var lastUpdated: Double? - @AppStorage("xcodeListCategory") private var category: Category = .all + @SceneStorage("isShowingInfoPane") private var isShowingInfoPane = false + @SceneStorage("xcodeListCategory") private var category: Category = .all var visibleXcodes: [Xcode] { var xcodes: [Xcode] @@ -40,47 +41,60 @@ struct XcodeListView: View { } var body: some View { - List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in - HStack { - appIconView(for: xcode) - - VStack(alignment: .leading) { - Text(xcode.description) - .font(.body) + HSplitView { + List(visibleXcodes, selection: $appState.selectedXcodeID) { xcode in + HStack { + appIconView(for: xcode) - Text(verbatim: xcode.path ?? "") - .font(.caption) - .foregroundColor(appState.selectedXcodeID == xcode.id ? Color(NSColor.selectedMenuItemTextColor) : Color(NSColor.secondaryLabelColor)) + VStack(alignment: .leading) { + Text(xcode.description) + .font(.body) + + Text(verbatim: xcode.path ?? "") + .font(.caption) + .foregroundColor(appState.selectedXcodeID == xcode.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) } - - 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 { - InstallButton(xcode: xcode) - - Divider() - - if xcode.installed { - SelectButton(xcode: xcode) - LaunchButton(xcode: xcode) - RevealButton(xcode: xcode) - CopyPathButton(xcode: xcode) + .contextMenu { + InstallButton(xcode: xcode) + + Divider() + + if xcode.installed { + SelectButton(xcode: xcode) + LaunchButton(xcode: xcode) + RevealButton(xcode: xcode) + CopyPathButton(xcode: xcode) + } } } + .frame(minWidth: 300) + .layoutPriority(1) + + InspectorPane() + .frame(minWidth: 300, maxWidth: .infinity) + .frame(width: isShowingInfoPane ? nil : 0) + .isHidden(!isShowingInfoPane) } - .mainToolbar(category: $category, searchText: $searchText) + .mainToolbar( + category: $category, + isShowingInfoPane: $isShowingInfoPane, + searchText: $searchText + ) .navigationSubtitle(subtitleText) .frame(minWidth: 200, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity) .alert(item: $appState.error) { error in diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 14036a1..b7e1b66 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -10,6 +10,7 @@ struct XcodesApp: App { var body: some Scene { WindowGroup("Xcodes") { XcodeListView() + .frame(minWidth: 600) .environmentObject(appState) // This is intentionally used on a View, and not on a WindowGroup, // so that it's triggered when an individual window's phase changes instead of all window phases.