Add trailing inspector pane

This commit is contained in:
Brandon Evans 2020-12-28 09:14:21 -07:00
parent 2316a19bd4
commit ba0c429766
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
10 changed files with 416 additions and 42 deletions

View file

@ -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 = "<group>"; };
CAFBDBA525990C76003DCC5A /* SimpleXPCApp.LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = SimpleXPCApp.LICENSE; sourceTree = "<group>"; };
CAFBDC4D2599B33D003DCC5A /* MainToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainToolbar.swift; sourceTree = "<group>"; };
CAFBDC67259A308B003DCC5A /* InspectorPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorPane.swift; sourceTree = "<group>"; };
CAFBDC6B259A3098003DCC5A /* View+Conditional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Conditional.swift"; sourceTree = "<group>"; };
/* 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 */,

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
import SwiftUI
extension View {
@ViewBuilder
func `if`<Other: View>(_ predicate: Bool, then: (Self) -> Other) -> some View {
if predicate {
then(self)
} else {
self
}
}
}

View file

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

View file

@ -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<XcodeListView.Category>,
category: Binding<XcodeListView.Category>,
isShowingInfoPane: Binding<Bool>,
searchText: Binding<String>
) -> some View {
self.modifier(MainToolbarModifier(category: category, searchText: searchText))
self.modifier(
MainToolbarModifier(
category: category,
isShowingInfoPane: isShowingInfoPane,
searchText: searchText
)
)
}
}

View file

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

View file

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