world-manager/World Manager for Minecraft/UI/Root/ContentView.swift

872 lines
30 KiB
Swift

// SPDX-FileCopyrightText: 2026 John Burwell and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@StateObject private var library: SourceLibrary
@State private var selectedItemID: MinecraftContentItem.ID?
@State private var selectedSidebarSelection: SidebarSelection?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var searchText = ""
@State private var isDropTargeted = false
@State private var isPerformingItemAction = false
@State private var isShowingDeviceSourceSheet = false
@State private var sortMode: ItemSortMode = .name
@State private var directoryPreviewContents: [DirectoryEntry] = []
@State private var showsProjectionLoadingState = false
@State private var itemListProjection = ItemCollectionProjection.placeholder(
for: ItemCollectionProjectionRequest(
selection: nil,
searchText: "",
sortMode: .name,
source: nil
)
)
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
private let deviceSourceFactory: ConnectedDeviceSourceFactory
private let itemActionService: ContentItemActionService
private let directoryPreviewLimit = 12
private let projectionLoadingDelay: Duration = .milliseconds(150)
init() {
let dependencies = ContentViewDependencies.makeDefault()
self.connectedDeviceAccess = dependencies.connectedDeviceAccess
self.deviceSourceFactory = dependencies.deviceSourceFactory
self.itemActionService = dependencies.itemActionService
_library = StateObject(
wrappedValue: dependencies.library
)
}
var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
let resolvedCurrentSource = currentSource
let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection,
searchText: searchText,
sortMode: sortMode,
source: resolvedCurrentSource
)
let fastPathProjection = fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request)
let reusesCurrentProjectedItems =
itemListProjection.request.selection == currentProjectionRequest.selection &&
itemListProjection.request.source?.id == currentProjectionRequest.source?.id
let resolvedItemListProjection = if let fastPathProjection {
fastPathProjection
} else if itemListProjection.request == currentProjectionRequest {
itemListProjection
} else {
ItemCollectionProjection.placeholder(for: currentProjectionRequest)
}
let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource)
let resolvedDisplayedItems: [MinecraftContentItem] = if let fastPathProjection {
fastPathProjection.items
} else if reusesCurrentProjectedItems {
itemListProjection.items
} else {
[]
}
NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView(
sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
selection: sidebarSelectionBinding,
addSourceAction: pickFolder,
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:),
rescanSourceAction: { source in
selectedSidebarSelection = .source(sourceID: source.id)
selectedItemID = nil
library.rescanSource(withID: source.id)
},
removeSourceAction: { source in
removeSource(source.id)
},
filters: sidebarFilters(for:)
)
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
} content: {
ItemListColumnView(
isEmpty: isEmptyLibrary,
isDropTargeted: $isDropTargeted,
selectedItemID: $selectedItemID,
searchText: $searchText,
sortMode: $sortMode,
showsHeader: shouldShowItemListHeader,
sourceName: resolvedItemListProjection.sourceName,
showsSourceName: !isSidebarVisible,
title: resolvedItemListProjection.title,
subtitle: resolvedItemListProjection.subtitle,
showsSubtitle: isSearching || showsProjectionLoadingState,
isRefreshing: resolvedCurrentSource?.isScanning == true,
showsProjectionLoadingState: showsProjectionLoadingState,
items: resolvedDisplayedItems,
searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:),
itemContextMenu: itemContextMenu(for:)
)
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
} detail: {
ItemDetailColumnView(
item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
worldsUsingPack: resolvedCurrentSelectedItem.map(worldsUsingPack(for:)) ?? [],
backingPackInstances: resolvedCurrentSelectedItem.map(backingPackInstances(for:)) ?? [],
isSuspiciousPack: resolvedCurrentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
contents: directoryPreviewContents,
directoryPreviewLimit: directoryPreviewLimit,
isEmpty: isEmptyLibrary,
isPerformingItemAction: isPerformingItemAction,
areFileActionsEnabled: areCurrentItemFileActionsEnabled,
exportTitle: resolvedCurrentSelectedItem.map(primaryActionTitle(for:)),
exportAction: {
guard let item = resolvedCurrentSelectedItem else {
return
}
saveItem(item)
},
revealAction: {
guard let item = resolvedCurrentSelectedItem else {
return
}
revealInFinder(item)
},
shareAction: { anchorView in
guard let item = resolvedCurrentSelectedItem else {
return
}
shareItem(item, from: anchorView)
}
)
.frame(minWidth: 450)
}
.overlay {
if library.isRestoringPersistedSources && isEmptyLibrary {
LaunchRestoreOverlayView()
}
}
.sheet(isPresented: $isShowingDeviceSourceSheet) {
ConnectedDeviceSourcePickerView(
deviceDiscoveryService: connectedDeviceAccess,
sourceFactory: deviceSourceFactory,
onAddSource: { source in
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
selectedSidebarSelection = .source(sourceID: sourceID)
selectedItemID = nil
isShowingDeviceSourceSheet = false
}
)
}
.task {
AppTerminationCoordinator.shared.register(library: library)
}
.disabled(library.isRestoringPersistedSources && isEmptyLibrary)
.onChange(of: resolvedDisplayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return
}
self.selectedItemID = nil
}
.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
syncSelection(with: library.visibleSources.map(\.id))
}
.task(id: currentProjectionRequest) {
let request = currentProjectionRequest
if let fastPathProjection = fastProjection(for: request, previousRequest: itemListProjection.request) {
showsProjectionLoadingState = false
itemListProjection = fastPathProjection
return
}
let projection = await Task.detached(priority: .userInitiated) {
ItemCollectionProjector.makeProjection(for: request)
}.value
guard !Task.isCancelled else {
return
}
showsProjectionLoadingState = false
itemListProjection = projection
}
.task(id: currentProjectionRequest) {
showsProjectionLoadingState = false
guard itemListProjection.request != currentProjectionRequest else {
return
}
guard fastProjection(for: currentProjectionRequest, previousRequest: itemListProjection.request) == nil else {
return
}
try? await Task.sleep(for: projectionLoadingDelay)
guard !Task.isCancelled else {
return
}
if itemListProjection.request != currentProjectionRequest {
showsProjectionLoadingState = true
}
}
.task(id: resolvedCurrentSelectedItem?.id) {
await refreshDirectoryPreviewContents()
}
}
private var sidebarSelectionBinding: Binding<SidebarSelection?> {
Binding(
get: { selectedSidebarSelection },
set: { newSelection in
if newSelection != selectedSidebarSelection {
selectedItemID = nil
}
selectedSidebarSelection = newSelection
}
)
}
private var currentSource: MinecraftSource? {
guard let sourceID = selectedSidebarSelection?.sourceID else {
return library.visibleSources.first
}
return library.source(withID: sourceID)
}
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else {
return nil
}
if let source,
let item = source.items.first(where: { $0.id == selectedItemID }) {
return item
}
if let sourceID = library.sourceID(forItemID: selectedItemID),
let source = library.source(withID: sourceID) {
return source.items.first(where: { $0.id == selectedItemID })
}
return nil
}
private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool {
switch sortMode {
case .name:
return { lhs, rhs in
lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
case .modifiedDate:
return { lhs, rhs in
switch (lhs.displayDate, rhs.displayDate) {
case let (lhsDate?, rhsDate?):
if lhsDate != rhsDate {
return lhsDate > rhsDate
}
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
break
}
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
case .size:
return { lhs, rhs in
switch (lhs.sizeBytes, rhs.sizeBytes) {
case let (lhsSize?, rhsSize?):
if lhsSize != rhsSize {
return lhsSize > rhsSize
}
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
break
}
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
}
}
private var areCurrentItemFileActionsEnabled: Bool {
guard currentSelectedItem(in: currentSource) != nil else {
return false
}
return currentSource?.availability == .available
}
private var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var isSidebarVisible: Bool {
columnVisibility == .all
}
private var shouldShowItemListHeader: Bool {
isSearching || !isSidebarVisible
}
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
let orderedKinds: [MinecraftContentKind] = [
.world,
.behaviorPack,
.resourcePack,
.dataPack,
.skinPack,
.worldTemplate,
.shaderPack,
.mod
]
return orderedKinds.compactMap { contentKind in
guard let count = source.displayItemCountsByKind[contentKind], count > 0 else {
return nil
}
return SidebarFilter(
title: sidebarTitle(for: contentKind),
iconName: sidebarIcon(for: contentKind),
count: count,
selection: .contentKind(sourceID: source.id, contentKind: contentKind)
)
}
}
private var isSourceOverviewSelection: Bool {
guard let selectedSidebarSelection else {
return false
}
if case .source = selectedSidebarSelection {
return true
}
return false
}
private func sidebarTitle(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
}
}
private func sidebarTitle(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .dataPack:
return "Data Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
case .shaderPack:
return "Shader Packs"
case .mod:
return "Mods"
}
}
private func sidebarIcon(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "doc.on.doc"
}
}
private func sidebarIcon(for contentKind: MinecraftContentKind) -> String {
switch contentKind {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .dataPack:
return "curlybraces.square"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "map"
case .shaderPack:
return "camera.filters"
case .mod:
return "hammer"
}
}
@ViewBuilder
private func itemContextMenu(for item: MinecraftContentItem) -> some View {
Button("Share...") {
shareItem(item, from: nil)
}
.disabled(!areFileActionsEnabled(for: item))
Button(exportMenuTitle(for: item)) {
saveItem(item)
}
.disabled(!areFileActionsEnabled(for: item))
Divider()
Button("Reveal in Finder") {
revealInFinder(item)
}
.disabled(!areFileActionsEnabled(for: item))
}
private func exportMenuTitle(for item: MinecraftContentItem) -> String {
switch item.contentType {
case .world:
return "Create Minecraft World File..."
case .behaviorPack, .resourcePack, .skinPack:
return "Create Minecraft Pack File..."
case .worldTemplate:
return "Create Minecraft Template File..."
}
}
private func primaryActionTitle(for item: MinecraftContentItem) -> String {
switch item.contentType {
case .world:
return "Create Minecraft World File..."
case .behaviorPack, .resourcePack, .skinPack:
return "Create Minecraft Pack File..."
case .worldTemplate:
return "Create Minecraft Template File..."
}
}
private func primaryActionSubtitle(for item: MinecraftContentItem) -> String {
switch item.contentType {
case .world:
return "Creates a .mcworld file that can be opened on another device to import this world into Minecraft."
case .behaviorPack, .resourcePack, .skinPack:
return "Creates a .mcpack file that can be shared or opened on another device."
case .worldTemplate:
return "Creates a .mctemplate file that can be opened on another device."
}
}
private func logicalPackReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] {
guard
item.contentType == .world,
let source = currentSource
else {
return []
}
return source.resolvedPackReferences(for: item.id, type: type)
}
private func worldsUsingPack(for item: MinecraftContentItem) -> [MinecraftContentItem] {
guard
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
let source = currentSource,
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
else {
return []
}
return source.worldsUsingPack(logicalPack.id).sorted(by: sortComparator)
}
private func backingPackInstances(for item: MinecraftContentItem) -> [MinecraftContentItem] {
guard
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
let source = currentSource,
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
else {
return []
}
return source
.packInstances(for: logicalPack.id)
.compactMap { source.rawItem(withID: $0.itemID) }
.sorted(by: WorldScanner.sortItems)
}
private func isSuspiciousPack(_ item: MinecraftContentItem) -> Bool {
guard
(item.contentType == .behaviorPack || item.contentType == .resourcePack),
let source = currentSource,
let logicalPack = source.logicalPack(forRepresentativeItemID: item.id)
else {
return false
}
return logicalPack.isSuspicious
}
private func refreshDirectoryPreviewContents() async {
guard let source = currentSource,
let item = currentSelectedItem(in: source) else {
await MainActor.run {
directoryPreviewContents = []
}
return
}
let contents = (try? await library.listContents(for: item, in: source)) ?? []
guard !Task.isCancelled else {
return
}
await MainActor.run {
directoryPreviewContents = Array(contents.prefix(directoryPreviewLimit))
}
}
private func pickFolder() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.title = "Add Minecraft Source Folders"
guard panel.runModal() == .OK else {
return
}
for url in panel.urls {
Task { @MainActor in
let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
}
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
guard !supportedProviders.isEmpty else {
return false
}
for provider in supportedProviders {
provider.loadDataRepresentation(forTypeIdentifier: fileURLType) { data, _ in
guard
let data,
let url = NSURL(absoluteURLWithDataRepresentation: data, relativeTo: nil) as URL?
else {
return
}
Task { @MainActor in
let sourceID = await library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
}
}
return true
}
private func selectSourceIfNeeded(_ sourceID: URL) {
guard selectedSidebarSelection == nil else {
return
}
selectedSidebarSelection = .source(sourceID: sourceID)
}
private func removeSource(_ sourceID: URL) {
let fallbackSourceID = library.visibleSources.first(where: { $0.id != sourceID })?.id
library.removeSource(withID: sourceID)
if selectedSidebarSelection?.sourceID == sourceID {
selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) }
}
if let selectedItemID, currentSelectedItem(in: currentSource)?.id != selectedItemID {
self.selectedItemID = nil
}
}
private func addConnectedDeviceSource(from entry: ConnectedDeviceSidebarEntry) {
guard let container = entry.minecraftContainer else {
return
}
let source = deviceSourceFactory.makeSource(device: entry.device, container: container)
let sourceID = library.addSource(source, shouldPersist: true, shouldScan: true)
selectedSidebarSelection = .source(sourceID: sourceID)
selectedItemID = nil
}
private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
self.selectedSidebarSelection = sourceIDs.first.map { .source(sourceID: $0) }
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .source(sourceID: firstSourceID)
}
if let selectedItemID {
if !library.containsItem(withID: selectedItemID) {
self.selectedItemID = nil
}
}
}
private func areFileActionsEnabled(for item: MinecraftContentItem) -> Bool {
guard
let sourceID = library.sourceID(forItemID: item.id),
let source = library.source(withID: sourceID)
else {
return false
}
return source.availability == .available && source.capabilities.canExportPortablePackages
}
private func sourceForItem(_ item: MinecraftContentItem) -> MinecraftSource? {
guard let sourceID = library.sourceID(forItemID: item.id) else {
return nil
}
return library.source(withID: sourceID)
}
private func fastProjection(
for request: ItemCollectionProjectionRequest,
previousRequest: ItemCollectionProjectionRequest
) -> ItemCollectionProjection? {
guard
let sourceID = request.source?.id,
sourceID == previousRequest.source?.id,
request.searchText == previousRequest.searchText,
request.sortMode == previousRequest.sortMode,
request.selection != previousRequest.selection
else {
return nil
}
return ItemCollectionProjector.makeProjection(for: request)
}
private func saveItem(_ item: MinecraftContentItem) {
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return
}
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.showsTagField = false
panel.title = exportMenuTitle(for: item)
panel.prompt = "Save"
panel.nameFieldStringValue = itemActionService.suggestedFilename(for: item)
panel.allowedContentTypes = [archiveType(for: item)]
guard panel.runModal() == .OK, let destinationURL = panel.url else {
return
}
isPerformingItemAction = true
Task {
do {
guard let source = currentSource else {
await MainActor.run {
isPerformingItemAction = false
}
return
}
let finalURL = try await Task.detached(priority: .userInitiated) {
let representation = try await library.externalRepresentation(
for: item,
in: source,
preferredKind: .portablePackage
)
return try itemActionService.persistExternalRepresentation(
representation,
to: destinationURL
)
}.value
await MainActor.run {
isPerformingItemAction = false
_ = finalURL
}
} catch {
await MainActor.run {
isPerformingItemAction = false
}
}
}
}
private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) {
guard !isPerformingItemAction, areFileActionsEnabled(for: item) else {
return
}
isPerformingItemAction = true
Task {
do {
guard let source = currentSource else {
await MainActor.run {
isPerformingItemAction = false
}
return
}
let shareURL = try await Task.detached(priority: .userInitiated) {
let representation = try await library.externalRepresentation(
for: item,
in: source,
preferredKind: .portablePackage
)
return representation.url
}.value
await MainActor.run {
isPerformingItemAction = false
let presentationView = anchorView ?? NSApp.keyWindow?.contentView
guard let presentationView else {
return
}
let picker = NSSharingServicePicker(items: [shareURL])
let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(
dx: presentationView.bounds.width / 2,
dy: presentationView.bounds.height / 2
)
picker.show(relativeTo: targetRect, of: presentationView, preferredEdge: .minY)
}
} catch {
await MainActor.run {
isPerformingItemAction = false
}
}
}
}
private func revealInFinder(_ item: MinecraftContentItem) {
guard let source = currentSource, areFileActionsEnabled(for: item) else {
return
}
guard !isPerformingItemAction else {
return
}
isPerformingItemAction = true
Task {
do {
let representation = try await library.externalRepresentation(
for: item,
in: source,
preferredKind: .nativeFolder
)
await MainActor.run {
isPerformingItemAction = false
NSWorkspace.shared.activateFileViewerSelecting([representation.url])
}
} catch {
await MainActor.run {
isPerformingItemAction = false
}
}
}
}
private func archiveType(for item: MinecraftContentItem) -> UTType {
itemActionService.archiveContentType(for: item)
}
private func dragProvider(for item: MinecraftContentItem) -> NSItemProvider {
let provider = NSItemProvider()
let contentType = archiveType(for: item)
provider.suggestedName = itemActionService.suggestedArchiveFilename(for: item)
provider.registerFileRepresentation(
forTypeIdentifier: contentType.identifier,
fileOptions: [],
visibility: .all
) { completion in
guard let source = sourceForItem(item), areFileActionsEnabled(for: item) else {
completion(nil, false, SourceAccessError.accessFailed(reason: "This item is not currently available for export."))
return nil
}
let task = Task {
do {
let representation = try await library.externalRepresentation(
for: item,
in: source,
preferredKind: .portablePackage
)
completion(representation.url, representation.isTemporary, nil)
} catch {
completion(nil, false, error)
}
}
let progress = Progress(totalUnitCount: 1)
progress.cancellationHandler = {
task.cancel()
}
return progress
}
return provider
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}