diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 8911aec..8e03b5c 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -15,8 +15,8 @@ struct ContentView: View { @State private var selectedSidebarSelection: SidebarSelection? @State private var searchText = "" @State private var isDropTargeted = false - @State private var exportAlert: ExportAlert? - @State private var isExportingSelectedWorld = false + @State private var itemActionAlert: ItemActionAlert? + @State private var isPerformingItemAction = false var body: some View { NavigationSplitView { @@ -68,6 +68,9 @@ struct ContentView: View { .padding(.vertical, 2) .contentShape(Rectangle()) .tag(item) + .contextMenu { + itemContextMenu(for: item) + } } .navigationTitle(contentListTitle) .searchable(text: $searchText, placement: .toolbar, prompt: "Search Content") @@ -78,7 +81,7 @@ struct ContentView: View { .foregroundStyle(.secondary) } else if let selectedItem = currentSelectedItem { ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 20) { HStack(alignment: .top, spacing: 16) { LargeItemThumbnailView(iconURL: selectedItem.iconURL) @@ -97,42 +100,81 @@ struct ContentView: View { Spacer(minLength: 0) - if selectedItem.contentType == .world { - Button { - exportSelectedWorld() - } label: { - if isExportingSelectedWorld { - ProgressView() - .controlSize(.small) - } else { - Label("Export .mcworld", systemImage: "square.and.arrow.up") - } + VStack(alignment: .trailing, spacing: 8) { + SharingPickerButton( + title: "Share", + systemImage: "square.and.arrow.up", + isEnabled: !isPerformingItemAction + ) { anchorView in + shareItem(selectedItem, from: anchorView) } - .disabled(isExportingSelectedWorld) + + Button { + saveItem(selectedItem) + } label: { + Label("Export .\(selectedItem.contentType.archiveExtension)", systemImage: "square.and.arrow.down") + } + .disabled(isPerformingItemAction) + + Button { + revealInFinder(selectedItem) + } label: { + Label("Reveal in Finder", systemImage: "folder") + } + .disabled(isPerformingItemAction) } } - detailRow(title: "Folder Path", value: selectedItem.folderURL.path) - detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path) + detailSection("Location") { + detailRow(title: "Folder Path", value: selectedItem.folderURL.path) + detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path) + } + + detailSection("Details") { + if let modifiedDate = selectedItem.modifiedDate { + detailRow( + title: "Modified", + value: modifiedDate.formatted(date: .abbreviated, time: .shortened) + ) + } + + if let sizeBytes = selectedItem.sizeBytes { + detailRow( + title: "Size", + value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + ) + } - if let modifiedDate = selectedItem.modifiedDate { detailRow( - title: "Modified", - value: modifiedDate.formatted(date: .abbreviated, time: .shortened) + title: "Metadata", + value: selectedItem.metadataLoaded ? "Loaded" : "Loading..." ) } - if let sizeBytes = selectedItem.sizeBytes { - detailRow( - title: "Size", - value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) - ) - } + detailSection("Contents") { + let previewEntries = directoryPreviewEntries(for: selectedItem) - detailRow( - title: "Metadata", - value: selectedItem.metadataLoaded ? "Loaded" : "Loading..." - ) + if previewEntries.isEmpty { + Text("No visible files or folders") + .foregroundStyle(.secondary) + } else { + ForEach(previewEntries) { entry in + HStack(spacing: 10) { + Image(systemName: entry.isDirectory ? "folder" : "doc") + .foregroundStyle(.secondary) + Text(entry.name) + .lineLimit(1) + Spacer() + } + } + + if previewEntries.count == directoryPreviewLimit { + Text("Showing the first \(directoryPreviewLimit) items") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } } .padding() } @@ -144,13 +186,28 @@ struct ContentView: View { .navigationTitle("Minecraft World Manager") .toolbar { ToolbarItemGroup(placement: .primaryAction) { - if selectedWorld != nil { - Button { - exportSelectedWorld() - } label: { - Label("Export .mcworld", systemImage: "square.and.arrow.up") + if let selectedExportableItem = selectedExportableItem { + SharingPickerButton( + title: nil, + systemImage: "square.and.arrow.up", + isEnabled: !isPerformingItemAction + ) { anchorView in + shareItem(selectedExportableItem, from: anchorView) } - .disabled(isExportingSelectedWorld) + + Button { + saveItem(selectedExportableItem) + } label: { + Label("Export", systemImage: "square.and.arrow.down") + } + .disabled(isPerformingItemAction) + + Button { + revealInFinder(selectedExportableItem) + } label: { + Label("Reveal in Finder", systemImage: "folder") + } + .disabled(isPerformingItemAction) } Button { @@ -202,7 +259,7 @@ struct ContentView: View { .onChange(of: library.sources.map(\.id)) { _, sourceIDs in syncSelection(with: sourceIDs) } - .alert(item: $exportAlert) { alert in + .alert(item: $itemActionAlert) { alert in Alert( title: Text(alert.title), message: Text(alert.message), @@ -211,6 +268,8 @@ struct ContentView: View { } } + private let directoryPreviewLimit = 12 + private var filteredItems: [MinecraftContentItem] { guard let selectedSidebarSelection else { return [] @@ -255,12 +314,8 @@ struct ContentView: View { .first(where: { $0.id == selectedItem.id }) ?? selectedItem } - private var selectedWorld: MinecraftContentItem? { - guard let currentSelectedItem, currentSelectedItem.contentType == .world else { - return nil - } - - return currentSelectedItem + private var selectedExportableItem: MinecraftContentItem? { + currentSelectedItem } private var contentListTitle: String { @@ -335,6 +390,16 @@ struct ContentView: View { } } + @ViewBuilder + private func detailSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.headline) + + content() + } + } + @ViewBuilder private func detailRow(title: String, value: String) -> some View { VStack(alignment: .leading, spacing: 4) { @@ -347,6 +412,50 @@ struct ContentView: View { } } + @ViewBuilder + private func itemContextMenu(for item: MinecraftContentItem) -> some View { + Button("Share...") { + shareItem(item, from: nil) + } + + Button("Export .\(item.contentType.archiveExtension)") { + saveItem(item) + } + + Divider() + + Button("Reveal in Finder") { + revealInFinder(item) + } + } + + private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] { + let fileManager = FileManager.default + + guard let urls = try? fileManager.contentsOfDirectory( + at: item.folderURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return urls + .map { url in + let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true + return DirectoryPreviewEntry(name: url.lastPathComponent, isDirectory: isDirectory) + } + .sorted { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { + return lhs.isDirectory && !rhs.isDirectory + } + + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + .prefix(directoryPreviewLimit) + .map { $0 } + } + private func pickFolder() { let panel = NSOpenPanel() panel.allowsMultipleSelection = true @@ -429,42 +538,42 @@ struct ContentView: View { } } - private func exportSelectedWorld() { - guard let world = selectedWorld, !isExportingSelectedWorld else { + private func saveItem(_ item: MinecraftContentItem) { + guard !isPerformingItemAction else { return } let panel = NSSavePanel() panel.canCreateDirectories = true panel.isExtensionHidden = false - panel.title = "Export Minecraft World" - panel.message = "Choose where to save the .mcworld file." - panel.nameFieldStringValue = WorldExporter.suggestedFilename(for: world) - panel.allowedContentTypes = [UTType(filenameExtension: "mcworld") ?? .data] + panel.title = "Export \(item.contentType.exportTitle)" + panel.message = "Choose where to save the .\(item.contentType.archiveExtension) file." + panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item) + panel.allowedContentTypes = [archiveType(for: item)] guard panel.runModal() == .OK, let destinationURL = panel.url else { return } - isExportingSelectedWorld = true + isPerformingItemAction = true Task { do { try await Task.detached(priority: .userInitiated) { - try WorldExporter.exportWorld(world, to: destinationURL) + try ContentPackageExporter.exportItem(item, to: destinationURL) }.value await MainActor.run { - isExportingSelectedWorld = false - exportAlert = ExportAlert( + isPerformingItemAction = false + itemActionAlert = ItemActionAlert( title: "Export Complete", - message: "\"\(world.displayName)\" was exported as a .mcworld file." + message: "\"\(item.displayName)\" was exported as \(ContentPackageExporter.suggestedFilename(for: item))." ) } } catch { await MainActor.run { - isExportingSelectedWorld = false - exportAlert = ExportAlert( + isPerformingItemAction = false + itemActionAlert = ItemActionAlert( title: "Export Failed", message: error.localizedDescription ) @@ -472,6 +581,55 @@ struct ContentView: View { } } } + + private func shareItem(_ item: MinecraftContentItem, from anchorView: NSView?) { + guard !isPerformingItemAction else { + return + } + + isPerformingItemAction = true + + Task { + do { + let shareURL = try await Task.detached(priority: .userInitiated) { + try ContentPackageExporter.prepareShareFile(for: item) + }.value + + await MainActor.run { + isPerformingItemAction = false + + let presentationView = anchorView ?? NSApp.keyWindow?.contentView + guard let presentationView else { + itemActionAlert = ItemActionAlert( + title: "Share Failed", + message: "Could not find a view to present the sharing menu." + ) + 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 + itemActionAlert = ItemActionAlert( + title: "Share Failed", + message: error.localizedDescription + ) + } + } + } + } + + private func revealInFinder(_ item: MinecraftContentItem) { + NSWorkspace.shared.activateFileViewerSelecting([item.folderURL]) + } + + private func archiveType(for item: MinecraftContentItem) -> UTType { + UTType(filenameExtension: item.contentType.archiveExtension) ?? .data + } } private enum SidebarSelection: Hashable { @@ -513,12 +671,65 @@ private struct SidebarFilterRow: View { } } -private struct ExportAlert: Identifiable { +private struct ItemActionAlert: Identifiable { let id = UUID() let title: String let message: String } +private struct DirectoryPreviewEntry: Identifiable { + let id = UUID() + let name: String + let isDirectory: Bool +} + +private struct SharingPickerButton: NSViewRepresentable { + let title: String? + let systemImage: String + let isEnabled: Bool + let action: (NSView) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.target = context.coordinator + button.action = #selector(Coordinator.didPressButton(_:)) + button.bezelStyle = .texturedRounded + update(button) + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + context.coordinator.action = action + update(nsView) + } + + private func update(_ button: NSButton) { + button.image = NSImage( + systemSymbolName: systemImage, + accessibilityDescription: title ?? "Share" + ) + button.imagePosition = title == nil ? .imageOnly : .imageLeading + button.title = title ?? "" + button.isEnabled = isEnabled + } + + final class Coordinator: NSObject { + var action: (NSView) -> Void + + init(action: @escaping (NSView) -> Void) { + self.action = action + } + + @objc func didPressButton(_ sender: NSButton) { + action(sender) + } + } +} + private struct EmptySourcesView: View { let isDropTargeted: Bool let chooseFolder: () -> Void diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift index 03a67e7..5765bbd 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -28,6 +28,32 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { return "world_templates" } } + + nonisolated var archiveExtension: String { + switch self { + case .world: + return "mcworld" + case .behaviorPack, .resourcePack, .skinPack: + return "mcpack" + case .worldTemplate: + return "mctemplate" + } + } + + nonisolated var exportTitle: String { + switch self { + case .world: + return "Minecraft World" + case .behaviorPack: + return "Behavior Pack" + case .resourcePack: + return "Resource Pack" + case .skinPack: + return "Skin Pack" + case .worldTemplate: + return "World Template" + } + } } struct MinecraftContentItem: Identifiable, Hashable, Sendable { diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift new file mode 100644 index 0000000..0fad00a --- /dev/null +++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift @@ -0,0 +1,147 @@ +// +// ContentPackageExporter.swift +// World Manager for Minecraft +// +// Created by John Burwell on 2026-05-25. +// + +import Foundation + +enum ContentPackageExporter { + enum ExportError: LocalizedError { + case failedToCreateArchive(String) + + var errorDescription: String? { + switch self { + case .failedToCreateArchive(let output): + return output.isEmpty ? "Failed to create the archive file." : output + } + } + } + + nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws { + let fileManager = FileManager.default + let archiveURL = normalizedArchiveURL(for: item, destinationURL: destinationURL) + let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager) + + defer { + try? fileManager.removeItem(at: temporaryArchiveURL) + } + + try createArchive(for: item, at: temporaryArchiveURL) + + if fileManager.fileExists(atPath: archiveURL.path) { + try fileManager.removeItem(at: archiveURL) + } + + try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL) + } + + nonisolated static func prepareShareFile(for item: MinecraftContentItem) throws -> URL { + let fileManager = FileManager.default + let shareDirectoryURL = fileManager.temporaryDirectory + .appendingPathComponent("MinecraftContentShares", isDirectory: true) + + try fileManager.createDirectory(at: shareDirectoryURL, withIntermediateDirectories: true) + + let archiveURL = uniqueArchiveURL( + in: shareDirectoryURL, + baseName: suggestedBaseFilename(for: item), + pathExtension: item.contentType.archiveExtension, + fileManager: fileManager + ) + + try createArchive(for: item, at: archiveURL) + return archiveURL + } + + nonisolated static func suggestedBaseFilename(for item: MinecraftContentItem) -> String { + sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName) + } + + nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String { + "\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)" + } + + nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.currentDirectoryURL = item.folderURL + process.arguments = [ + "-c", + "-k", + "--norsrc", + ".", + archiveURL.path + ] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = outputPipe + + try process.run() + process.waitUntilExit() + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: outputData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard process.terminationStatus == 0 else { + throw ExportError.failedToCreateArchive(output) + } + } + + nonisolated private static func normalizedArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL { + let normalizedDestinationURL = destinationURL.standardizedFileURL + let requiredExtension = item.contentType.archiveExtension + + if normalizedDestinationURL.pathExtension.lowercased() == requiredExtension { + return normalizedDestinationURL + } + + return normalizedDestinationURL.appendingPathExtension(requiredExtension) + } + + nonisolated private static func temporaryArchiveURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL { + fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(item.contentType.archiveExtension) + } + + nonisolated private static func uniqueArchiveURL( + in directoryURL: URL, + baseName: String, + pathExtension: String, + fileManager: FileManager + ) -> URL { + var candidateURL = directoryURL + .appendingPathComponent(baseName) + .appendingPathExtension(pathExtension) + var suffix = 2 + + while fileManager.fileExists(atPath: candidateURL.path) { + candidateURL = directoryURL + .appendingPathComponent("\(baseName) \(suffix)") + .appendingPathExtension(pathExtension) + suffix += 1 + } + + return candidateURL + } + + nonisolated private static func sanitizedFilename(_ value: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>") + let components = value.components(separatedBy: invalidCharacters) + let collapsed = components.joined(separator: " ") + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let normalizedWhitespace = collapsed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + + return normalizedWhitespace.isEmpty ? "Minecraft Content" : normalizedWhitespace + } +} diff --git a/World Manager for Minecraft/Services/WorldExporter.swift b/World Manager for Minecraft/Services/WorldExporter.swift deleted file mode 100644 index 9d1b2c2..0000000 --- a/World Manager for Minecraft/Services/WorldExporter.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// WorldExporter.swift -// World Manager for Minecraft -// -// Created by John Burwell on 2026-05-25. -// - -import Foundation - -enum WorldExporter { - enum ExportError: LocalizedError { - case unsupportedContentType - case failedToCreateArchive(String) - - var errorDescription: String? { - switch self { - case .unsupportedContentType: - return "Only Minecraft worlds can be exported as .mcworld files." - case .failedToCreateArchive(let output): - return output.isEmpty ? "Failed to create the .mcworld archive." : output - } - } - } - - nonisolated static func exportWorld(_ item: MinecraftContentItem, to destinationURL: URL) throws { - guard item.contentType == .world else { - throw ExportError.unsupportedContentType - } - - let fileManager = FileManager.default - let normalizedDestinationURL = destinationURL.standardizedFileURL - let archiveURL = normalizedDestinationURL.pathExtension.lowercased() == "mcworld" - ? normalizedDestinationURL - : normalizedDestinationURL.appendingPathExtension("mcworld") - - let temporaryArchiveURL = fileManager.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - .appendingPathExtension("mcworld") - - defer { - try? fileManager.removeItem(at: temporaryArchiveURL) - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.currentDirectoryURL = item.folderURL - process.arguments = [ - "-c", - "-k", - "--norsrc", - ".", - temporaryArchiveURL.path - ] - - let outputPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = outputPipe - - try process.run() - process.waitUntilExit() - - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: outputData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - guard process.terminationStatus == 0 else { - throw ExportError.failedToCreateArchive(output) - } - - if fileManager.fileExists(atPath: archiveURL.path) { - try fileManager.removeItem(at: archiveURL) - } - - try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL) - } - - nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String { - let baseName = sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName) - return "\(baseName).mcworld" - } - - nonisolated private static func sanitizedFilename(_ value: String) -> String { - let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>") - let components = value.components(separatedBy: invalidCharacters) - let collapsed = components.joined(separator: " ") - .replacingOccurrences(of: "\n", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - - let normalizedWhitespace = collapsed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - - return normalizedWhitespace.isEmpty ? "Minecraft World" : normalizedWhitespace - } -}