Add connected device candidate details

This commit is contained in:
John Burwell 2026-06-02 16:00:19 -05:00
parent bd177832c0
commit ba6edf6cc4
8 changed files with 200 additions and 13 deletions

View File

@ -115,7 +115,7 @@ nonisolated struct MinecraftSource: Identifiable, Hashable, Sendable {
}
switch selection {
case .sourceCandidate:
case .sourceCandidate, .connectedDevice:
return []
case .source(let sourceID), .allContent(let sourceID):
guard sourceID == id else {

View File

@ -101,6 +101,22 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
visibleSources
}
var sidebarConnectedDevices: [ConnectedDeviceSidebarEntry] {
connectedDevices.filter { entry in
guard entry.matchedSourceID == nil else {
return false
}
return !sources.contains { source in
guard case .connectedDevice(let device, _) = source.origin else {
return false
}
return device.udid == entry.device.udid
}
}
}
func sourceID(forItemID itemID: URL) -> URL? {
sourceIDByItemID[itemID]
}

View File

@ -0,0 +1,133 @@
// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI
struct ConnectedDeviceDetailView: View {
let entry: ConnectedDeviceSidebarEntry
let addAction: (() -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(entry.device.name)
.font(.largeTitle.weight(.semibold))
Text("Available connected device")
.foregroundStyle(.secondary)
}
if let addAction {
Button("Add Source") {
addAction()
}
.buttonStyle(.borderedProminent)
}
sourceSection(title: "Overview", rows: overviewRows)
sourceSection(title: "Minecraft Access", rows: minecraftRows)
sourceSection(title: "Technical Details", rows: technicalRows)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(28)
}
}
private var overviewRows: [(String, String)] {
var rows: [(String, String)] = [
("Connection", connectionLabel),
("Trust State", trustStateLabel),
("Availability", entry.hasMinecraftContainer ? "Ready to add" : "Not ready")
]
if let productType = entry.device.productType, !productType.isEmpty {
rows.append(("Product Type", productType))
}
if let osVersion = entry.device.osVersion, !osVersion.isEmpty {
rows.append(("OS Version", osVersion))
}
return rows
}
private var minecraftRows: [(String, String)] {
if let error = entry.discoveryErrorDescription, !error.isEmpty {
return [("Discovery Error", error)]
}
guard let container = entry.minecraftContainer else {
return [("Minecraft Container", "Not found")]
}
var rows: [(String, String)] = [
("Minecraft Container", container.appName),
("App ID", container.appID),
("Access Mode", container.accessMode.rawValue)
]
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
rows.append(("Minecraft Path", relativePath))
}
return rows
}
private var technicalRows: [(String, String)] {
[
("UDID", entry.device.udid),
("Device ID", entry.id)
]
}
private var connectionLabel: String {
switch entry.device.connection {
case .usb:
return "USB"
case .network:
return "Network"
}
}
private var trustStateLabel: String {
switch entry.device.trustState {
case .trusted:
return "Trusted"
case .locked:
return "Locked"
case .untrusted:
return "Untrusted"
case .unavailable:
return "Unavailable"
}
}
@ViewBuilder
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.appSectionTitleStyle(.section)
VStack(spacing: 0) {
ForEach(rows, id: \.0) { title, value in
detailRow(title: title, value: value)
}
}
.appDetailSectionCard()
}
}
@ViewBuilder
private func detailRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(title)
.appTextStyle(.fieldLabel)
.frame(width: 150, alignment: .leading)
Text(value)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}

View File

@ -8,6 +8,7 @@ struct ItemDetailColumnView: View {
let item: MinecraftContentItem?
let source: MinecraftSource?
let sourceCandidate: SourceCandidate?
let connectedDevice: ConnectedDeviceSidebarEntry?
let showsSourceDetails: Bool
let behaviorPacks: [ContentPackReference]
let resourcePacks: [ContentPackReference]
@ -25,11 +26,11 @@ struct ItemDetailColumnView: View {
let shareAction: (NSView?) -> Void
let addCandidateSourceAction: (SourceCandidate) -> Void
let revealCandidateAction: (SourceCandidate) -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
var body: some View {
Group {
if isEmpty {
} else if let item {
if let item {
ItemDetailView(
item: item,
source: source,
@ -59,6 +60,14 @@ struct ItemDetailColumnView: View {
revealCandidateAction(sourceCandidate)
}
)
} else if let connectedDevice {
ConnectedDeviceDetailView(
entry: connectedDevice,
addAction: connectedDevice.hasMinecraftContainer ? {
addConnectedDeviceAction(connectedDevice)
} : nil
)
} else if isEmpty {
} else {
Text("Select a world or pack to see details")
.foregroundStyle(.secondary)

View File

@ -356,6 +356,7 @@ struct ItemDetailColumnPreviewContainer: View {
item: PreviewFixtures.featuredWorld,
source: PreviewFixtures.primarySource,
sourceCandidate: nil,
connectedDevice: nil,
showsSourceDetails: false,
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
@ -372,7 +373,8 @@ struct ItemDetailColumnPreviewContainer: View {
revealAction: {},
shareAction: { _ in },
addCandidateSourceAction: { _ in },
revealCandidateAction: { _ in }
revealCandidateAction: { _ in },
addConnectedDeviceAction: { _ in }
)
}
}

View File

@ -43,9 +43,10 @@ struct ContentView: View {
}
var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
let isEmptyLibrary = library.visibleSources.isEmpty && library.sidebarConnectedDevices.isEmpty && library.sourceCandidates.isEmpty
let resolvedCurrentSource = currentSource
let resolvedCurrentSourceCandidate = currentSourceCandidate
let resolvedCurrentConnectedDevice = currentConnectedDevice
let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection,
searchText: searchText,
@ -75,7 +76,7 @@ struct ContentView: View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView(
sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
connectedDevices: library.sidebarConnectedDevices,
sourceCandidates: library.sourceCandidates,
isDiscoveringSourceCandidates: library.isDiscoveringSourceCandidates,
selection: sidebarSelectionBinding,
@ -124,6 +125,7 @@ struct ContentView: View {
item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource,
sourceCandidate: resolvedCurrentSourceCandidate,
connectedDevice: resolvedCurrentConnectedDevice,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
@ -158,7 +160,8 @@ struct ContentView: View {
shareItem(item, from: anchorView)
},
addCandidateSourceAction: addCandidateSource(_:),
revealCandidateAction: revealCandidateInFinder(_:)
revealCandidateAction: revealCandidateInFinder(_:),
addConnectedDeviceAction: addConnectedDeviceSource(from:)
)
.frame(minWidth: 450)
}
@ -193,7 +196,7 @@ struct ContentView: View {
.onChange(of: library.sources.map(\.id)) { _, _ in
syncSelection(with: library.visibleSources.map(\.id))
}
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
.onChange(of: library.sidebarConnectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
syncSelection(with: library.visibleSources.map(\.id))
}
.task(id: currentProjectionRequest) {
@ -267,6 +270,14 @@ struct ContentView: View {
return library.sourceCandidates.first { $0.id == candidateID }
}
private var currentConnectedDevice: ConnectedDeviceSidebarEntry? {
guard case .connectedDevice(let deviceID) = selectedSidebarSelection else {
return nil
}
return library.sidebarConnectedDevices.first { $0.id == deviceID }
}
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else {
return nil
@ -676,6 +687,10 @@ struct ContentView: View {
if !library.sourceCandidates.contains(where: { $0.id == candidateID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .connectedDevice(let deviceID):
if !library.sidebarConnectedDevices.contains(where: { $0.id == deviceID }) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
}
case .source, .allContent, .contentType, .contentKind:
if let selectedSourceID = selectedSidebarSelection.sourceID,
!sourceIDs.contains(selectedSourceID) {

View File

@ -79,6 +79,8 @@ enum ItemCollectionProjector {
switch selection {
case .sourceCandidate:
return "Source Candidate"
case .connectedDevice:
return "Connected Device"
case .source, .allContent:
return "All Items"
case .contentType(_, let contentType):
@ -90,7 +92,7 @@ enum ItemCollectionProjector {
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection {
case .some(.sourceCandidate):
case .some(.sourceCandidate), .some(.connectedDevice):
return "Search Library"
case .some(.source):
return "Search \(source?.displayName ?? "Library")"
@ -109,6 +111,8 @@ enum ItemCollectionProjector {
switch selection {
case .some(.sourceCandidate):
return "Source Candidate"
case .some(.connectedDevice):
return "Connected Device"
case .some(.source):
return "Library"
case .some(.allContent):
@ -128,7 +132,7 @@ enum ItemCollectionProjector {
}
switch selection {
case .sourceCandidate:
case .sourceCandidate, .connectedDevice:
return "items"
case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items"

View File

@ -6,6 +6,7 @@ import SwiftUI
enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL)
case sourceCandidate(candidateID: String)
case connectedDevice(deviceID: String)
case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType)
case contentKind(sourceID: URL, contentKind: MinecraftContentKind)
@ -14,7 +15,7 @@ enum SidebarSelection: Hashable, Sendable {
switch self {
case .source(let sourceID), .allContent(let sourceID), .contentType(let sourceID, _), .contentKind(let sourceID, _):
return sourceID
case .sourceCandidate:
case .sourceCandidate, .connectedDevice:
return nil
}
}
@ -152,10 +153,14 @@ struct SourcesSidebarView: View {
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
ConnectedDeviceRow(
entry: entry,
onSelect: {
selection = .connectedDevice(deviceID: entry.id)
},
addAction: entry.hasMinecraftContainer ? {
addConnectedDeviceAction(entry)
} : nil
)
.tag(SidebarSelection.connectedDevice(deviceID: entry.id) as SidebarSelection?)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 0, trailing: 8))
}
@ -185,9 +190,9 @@ private struct SourceCandidateRow: View {
Spacer(minLength: 8)
Button(action: addAction) {
Image(systemName: "plus")
Text("Add")
}
.buttonStyle(.borderless)
.appMiniProminentButton()
.help("Add Source")
}
.contentShape(Rectangle())
@ -395,6 +400,7 @@ private struct CircularScanProgressView: View {
private struct ConnectedDeviceRow: View {
let entry: ConnectedDeviceSidebarEntry
let onSelect: () -> Void
let addAction: (() -> Void)?
var body: some View {
@ -424,6 +430,8 @@ private struct ConnectedDeviceRow: View {
}
}
.opacity(addAction == nil ? 0.68 : 1)
.contentShape(Rectangle())
.onTapGesture(perform: onSelect)
}
private var iconName: String {