diff --git a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift index e8321ac..e5c81ea 100644 --- a/World Manager for Minecraft/Models/Sources/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/Sources/MinecraftSource.swift @@ -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 { diff --git a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift index 29d1636..8ce6896 100644 --- a/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/Sources/Core/SourceLibrary.swift @@ -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] } diff --git a/World Manager for Minecraft/UI/Detail/ConnectedDeviceDetailView.swift b/World Manager for Minecraft/UI/Detail/ConnectedDeviceDetailView.swift new file mode 100644 index 0000000..1785c74 --- /dev/null +++ b/World Manager for Minecraft/UI/Detail/ConnectedDeviceDetailView.swift @@ -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) + } +} diff --git a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift index c2f9385..df267cd 100644 --- a/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/UI/Detail/ItemDetailColumnViews.swift @@ -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) diff --git a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift index 6bd15e4..51b13f8 100644 --- a/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift +++ b/World Manager for Minecraft/UI/Preview/PreviewFixtures.swift @@ -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 } ) } } diff --git a/World Manager for Minecraft/UI/Root/ContentView.swift b/World Manager for Minecraft/UI/Root/ContentView.swift index c297064..12b4bea 100644 --- a/World Manager for Minecraft/UI/Root/ContentView.swift +++ b/World Manager for Minecraft/UI/Root/ContentView.swift @@ -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) { diff --git a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift index 280c4f0..fdc5563 100644 --- a/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift +++ b/World Manager for Minecraft/UI/Root/ItemCollectionProjection.swift @@ -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" diff --git a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift index 9402b03..9062268 100644 --- a/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift +++ b/World Manager for Minecraft/UI/Sidebar/SidebarColumnViews.swift @@ -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 {