More layout finicking on the details pane. refactor layouts out of the main contentview file
This commit is contained in:
parent
ef86972724
commit
b25f2e0148
623
World Manager for Minecraft/ContentUIShared.swift
Normal file
623
World Manager for Minecraft/ContentUIShared.swift
Normal file
@ -0,0 +1,623 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
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.isBordered = false
|
||||||
|
button.bezelStyle = .regularSquare
|
||||||
|
button.contentTintColor = .white
|
||||||
|
button.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||||
|
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.isEnabled = isEnabled
|
||||||
|
button.attributedTitle = NSAttributedString(
|
||||||
|
string: title ?? "",
|
||||||
|
attributes: [
|
||||||
|
.foregroundColor: NSColor.white,
|
||||||
|
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject {
|
||||||
|
var action: (NSView) -> Void
|
||||||
|
|
||||||
|
init(action: @escaping (NSView) -> Void) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func didPressButton(_ sender: NSButton) {
|
||||||
|
action(sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActionPillButton: View {
|
||||||
|
enum Prominence {
|
||||||
|
case primary
|
||||||
|
case secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
var isDisabled = false
|
||||||
|
var prominence: Prominence = .secondary
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HeroActionLabel(title: title, systemImage: systemImage)
|
||||||
|
}
|
||||||
|
.buttonStyle(HeroActionButtonStyle(prominence: prominence))
|
||||||
|
.disabled(isDisabled)
|
||||||
|
.opacity(isDisabled ? 0.55 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharingPillButton: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
let isEnabled: Bool
|
||||||
|
let action: (NSView?) -> Void
|
||||||
|
@State private var anchorView: NSView?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
action(anchorView)
|
||||||
|
} label: {
|
||||||
|
HeroActionLabel(title: title, systemImage: systemImage)
|
||||||
|
}
|
||||||
|
.buttonStyle(HeroActionButtonStyle(prominence: .secondary))
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.opacity(isEnabled ? 1 : 0.55)
|
||||||
|
.background {
|
||||||
|
ShareAnchorView(anchorView: $anchorView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeroActionLabel: View {
|
||||||
|
let title: String
|
||||||
|
let systemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeroActionButtonStyle: ButtonStyle {
|
||||||
|
let prominence: ActionPillButton.Prominence
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(backgroundColor.opacity(configuration.isPressed ? pressedOpacity : 1), in: Capsule())
|
||||||
|
.overlay {
|
||||||
|
if prominence == .secondary {
|
||||||
|
Capsule()
|
||||||
|
.strokeBorder(.white.opacity(0.14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
switch prominence {
|
||||||
|
case .primary:
|
||||||
|
return .appAccent
|
||||||
|
case .secondary:
|
||||||
|
return Color.black.opacity(0.22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pressedOpacity: CGFloat {
|
||||||
|
switch prominence {
|
||||||
|
case .primary:
|
||||||
|
return 0.88
|
||||||
|
case .secondary:
|
||||||
|
return 0.72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ShareAnchorView: NSViewRepresentable {
|
||||||
|
@Binding var anchorView: NSView?
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView(frame: .zero)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
anchorView = view
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
if anchorView !== nsView {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
anchorView = nsView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordHeroView: View {
|
||||||
|
private let heroHeight: CGFloat = 360
|
||||||
|
private let heroTopPadding: CGFloat = 74
|
||||||
|
private let heroBottomPadding: CGFloat = 20
|
||||||
|
private let titleLineLimit = 3
|
||||||
|
private let titleMinimumScale: CGFloat = 0.82
|
||||||
|
private let detailsColumnSpacing: CGFloat = 18
|
||||||
|
|
||||||
|
let item: MinecraftContentItem
|
||||||
|
let metadataChips: [String]
|
||||||
|
let fallbackSystemImage: String
|
||||||
|
let copyAction: () -> Void
|
||||||
|
let actionRow: AnyView
|
||||||
|
let contentMaxWidth: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let innerHeight = max(0, proxy.size.height - heroTopPadding - heroBottomPadding)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
artworkBackground
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.black.opacity(0.12), .black.opacity(0.22), .black.opacity(0.5)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 28) {
|
||||||
|
HeroThumbnailView(
|
||||||
|
iconURL: item.iconURL,
|
||||||
|
contentType: item.contentType,
|
||||||
|
availableHeight: innerHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: detailsColumnSpacing) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Text(item.displayName)
|
||||||
|
.font(titleFont)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(titleLineLimit)
|
||||||
|
.minimumScaleFactor(titleMinimumScale)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
|
||||||
|
Button(action: copyAction) {
|
||||||
|
Image(systemName: "document.on.document")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.88))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordHeroChips
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 16)
|
||||||
|
|
||||||
|
actionRow
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: innerHeight, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: contentMaxWidth, maxHeight: innerHeight, alignment: .topLeading)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.top, heroTopPadding)
|
||||||
|
.padding(.bottom, heroBottomPadding)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: heroHeight, alignment: .top)
|
||||||
|
.clipShape(Rectangle())
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var artworkBackground: some View {
|
||||||
|
Group {
|
||||||
|
if let image = loadImage(from: item.iconURL) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.blur(radius: 36)
|
||||||
|
.saturation(1.08)
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.appAccent.opacity(0.9), Color.black.opacity(0.8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: fallbackSystemImage)
|
||||||
|
.font(.system(size: 84))
|
||||||
|
.foregroundStyle(.white.opacity(0.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recordHeroChips: some View {
|
||||||
|
FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in
|
||||||
|
Text(chip)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.95))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(.white.opacity(0.14), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var titleFont: Font {
|
||||||
|
.system(
|
||||||
|
item.displayName.count > 70 ? .title2 : .largeTitle,
|
||||||
|
design: .rounded
|
||||||
|
).weight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HeroThumbnailView: View {
|
||||||
|
private let thumbnailMaxWidth: CGFloat = 320
|
||||||
|
private let thumbnailMaxHeight: CGFloat = 200
|
||||||
|
private let thumbnailMinHeight: CGFloat = 120
|
||||||
|
|
||||||
|
let iconURL: URL?
|
||||||
|
let contentType: MinecraftContentType
|
||||||
|
let availableHeight: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let image = loadImage(from: iconURL) {
|
||||||
|
let frame = thumbnailFrame(for: image.size)
|
||||||
|
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: frame.width, height: frame.height)
|
||||||
|
.clipped()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 28)
|
||||||
|
.fill(.white.opacity(0.14))
|
||||||
|
.frame(width: 220, height: 160)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: fallbackIconName)
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(.white.opacity(0.75))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial.opacity(0.55), in: RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.14))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 22, y: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thumbnailFrame(for imageSize: CGSize) -> CGSize {
|
||||||
|
let aspectRatio = max(0.4, min(3.0, imageSize.width / max(1, imageSize.height)))
|
||||||
|
let maxHeight = min(thumbnailMaxHeight, max(thumbnailMinHeight, availableHeight))
|
||||||
|
let widthFromHeight = maxHeight * aspectRatio
|
||||||
|
|
||||||
|
if widthFromHeight <= thumbnailMaxWidth {
|
||||||
|
return CGSize(width: widthFromHeight, height: maxHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGSize(width: thumbnailMaxWidth, height: thumbnailMaxWidth / aspectRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallbackIconName: 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LaunchRestoreOverlayView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Text("Opening Library…")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.vertical, 28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.background.opacity(0.92))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PackReferenceIconView: View {
|
||||||
|
let iconURL: URL?
|
||||||
|
let fallbackSystemImage: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let image = loadImage(from: iconURL) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(.quaternary)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: fallbackSystemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptySourcesView: View {
|
||||||
|
let isDropTargeted: Bool
|
||||||
|
let chooseFolder: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 24)
|
||||||
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
||||||
|
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 220, height: 160)
|
||||||
|
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
.font(.system(size: 56, weight: .regular))
|
||||||
|
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text("Add a Minecraft Source")
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Choose Minecraft Folder...") {
|
||||||
|
chooseFolder()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemThumbnailView: View {
|
||||||
|
let iconURL: URL?
|
||||||
|
let fallbackSystemImage: String = "shippingbox"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let image = loadImage(from: iconURL) {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
|
} else {
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(.quaternary)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: fallbackSystemImage)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FlexibleTagLayout<Item: Hashable, Content: View>: View {
|
||||||
|
let spacing: CGFloat
|
||||||
|
let rowSpacing: CGFloat
|
||||||
|
let items: [Item]
|
||||||
|
@ViewBuilder let content: (Item) -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
generateContent(in: geometry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minHeight: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateContent(in geometry: GeometryProxy) -> some View {
|
||||||
|
var width = CGFloat.zero
|
||||||
|
var height = CGFloat.zero
|
||||||
|
|
||||||
|
return ZStack(alignment: .topLeading) {
|
||||||
|
ForEach(items, id: \.self) { item in
|
||||||
|
content(item)
|
||||||
|
.alignmentGuide(.leading) { dimensions in
|
||||||
|
if abs(width - dimensions.width) > geometry.size.width {
|
||||||
|
width = 0
|
||||||
|
height -= dimensions.height + rowSpacing
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = width
|
||||||
|
if item == items.last {
|
||||||
|
width = 0
|
||||||
|
} else {
|
||||||
|
width -= dimensions.width + spacing
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
.alignmentGuide(.top) { _ in
|
||||||
|
let result = height
|
||||||
|
if item == items.last {
|
||||||
|
height = 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StorageBreakdown {
|
||||||
|
let primaryDataSize: Int64?
|
||||||
|
let resourceSize: Int64?
|
||||||
|
let metadataSize: Int64?
|
||||||
|
|
||||||
|
static let loading = StorageBreakdown(primaryDataSize: nil, resourceSize: nil, metadataSize: nil)
|
||||||
|
|
||||||
|
nonisolated static func build(for item: MinecraftContentItem) -> StorageBreakdown {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
let dbURL = item.folderURL.appendingPathComponent("db", isDirectory: true)
|
||||||
|
let behaviorURL = item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true)
|
||||||
|
let resourceURL = item.folderURL.appendingPathComponent("resource_packs", isDirectory: true)
|
||||||
|
|
||||||
|
let primarySize = folderSize(at: dbURL, fileManager: fileManager)
|
||||||
|
let resourcesSize = (folderSize(at: behaviorURL, fileManager: fileManager) ?? 0)
|
||||||
|
+ (folderSize(at: resourceURL, fileManager: fileManager) ?? 0)
|
||||||
|
let totalSize = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||||
|
let metadata = totalSize.map { total in
|
||||||
|
max(0, total - (primarySize ?? 0) - resourcesSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return StorageBreakdown(
|
||||||
|
primaryDataSize: primarySize,
|
||||||
|
resourceSize: resourcesSize == 0 ? nil : resourcesSize,
|
||||||
|
metadataSize: metadata
|
||||||
|
)
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
let totalSize = folderSize(at: item.folderURL, fileManager: fileManager)
|
||||||
|
return StorageBreakdown(
|
||||||
|
primaryDataSize: totalSize,
|
||||||
|
resourceSize: nil,
|
||||||
|
metadataSize: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryDataText: String {
|
||||||
|
sizeText(for: primaryDataSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resourcesText: String {
|
||||||
|
sizeText(for: resourceSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadataText: String {
|
||||||
|
sizeText(for: metadataSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sizeText(for size: Int64?) -> String {
|
||||||
|
guard let size else {
|
||||||
|
return "Unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
|
||||||
|
guard fileManager.fileExists(atPath: folderURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let enumerator = fileManager.enumerator(
|
||||||
|
at: folderURL,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSize: Int64 = 0
|
||||||
|
|
||||||
|
for case let fileURL as URL in enumerator {
|
||||||
|
guard
|
||||||
|
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
|
||||||
|
values.isRegularFile == true,
|
||||||
|
let fileSize = values.fileSize
|
||||||
|
else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize += Int64(fileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImage(from url: URL?) -> NSImage? {
|
||||||
|
guard let url else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return NSImage(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyToPasteboard(_ value: String) {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(value, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
static let appAccent = Color("AccentColor")
|
||||||
|
}
|
||||||
@ -57,6 +57,7 @@ struct ContentView: View {
|
|||||||
} detail: {
|
} detail: {
|
||||||
ItemDetailColumnView(
|
ItemDetailColumnView(
|
||||||
item: currentSelectedItem,
|
item: currentSelectedItem,
|
||||||
|
source: currentSource,
|
||||||
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
|
||||||
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
|
||||||
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
|
||||||
@ -650,837 +651,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SidebarSelection: Hashable {
|
|
||||||
case allContent(sourceID: URL)
|
|
||||||
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
|
||||||
|
|
||||||
var sourceID: URL {
|
|
||||||
switch self {
|
|
||||||
case .allContent(let sourceID), .contentType(let sourceID, _):
|
|
||||||
return sourceID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ItemSortMode: String, CaseIterable, Identifiable {
|
|
||||||
case name
|
|
||||||
case modifiedDate
|
|
||||||
case size
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .name:
|
|
||||||
return "Name"
|
|
||||||
case .modifiedDate:
|
|
||||||
return "Modified Date"
|
|
||||||
case .size:
|
|
||||||
return "Size"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SidebarFilter: Identifiable, Hashable {
|
|
||||||
var id: SidebarSelection { selection }
|
|
||||||
let title: String
|
|
||||||
let iconName: String
|
|
||||||
let count: Int
|
|
||||||
let selection: SidebarSelection
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SourcesSidebarView: View {
|
|
||||||
let sources: [MinecraftSource]
|
|
||||||
@Binding var selection: SidebarSelection?
|
|
||||||
let footerState: SidebarFooterState
|
|
||||||
let addSourceAction: () -> Void
|
|
||||||
let rescanSourceAction: (MinecraftSource) -> Void
|
|
||||||
let removeSourceAction: (MinecraftSource) -> Void
|
|
||||||
let revealFooterURLAction: (URL) -> Void
|
|
||||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List(selection: $selection) {
|
|
||||||
Section {
|
|
||||||
ForEach(sources) { source in
|
|
||||||
SourceHeaderRow(title: source.displayName)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.padding(.top, 6)
|
|
||||||
.contextMenu {
|
|
||||||
Button("Rescan \"\(source.displayName)\"") {
|
|
||||||
rescanSourceAction(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
|
||||||
removeSourceAction(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(filters(source)) { filter in
|
|
||||||
SidebarFilterRow(filter: filter, isIndented: true)
|
|
||||||
.tag(filter.selection as SidebarSelection?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
SidebarSourcesSectionHeaderView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.sidebar)
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if footerState.style != .idle {
|
|
||||||
SidebarFooterView(
|
|
||||||
state: footerState,
|
|
||||||
revealAction: revealFooterURLAction
|
|
||||||
)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.bottom, 10)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem {
|
|
||||||
Button(action: addSourceAction) {
|
|
||||||
Image(systemName: "folder.badge.plus")
|
|
||||||
}
|
|
||||||
.help("Add Source Folder")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SidebarFilterRow: View {
|
|
||||||
let filter: SidebarFilter
|
|
||||||
let isIndented: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: filter.iconName)
|
|
||||||
.frame(width: 16)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text(filter.title)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(filter.count, format: .number)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.leading, isIndented ? 16 : 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SidebarSourcesSectionHeaderView: View {
|
|
||||||
var body: some View {
|
|
||||||
Text("Libraries")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.textCase(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SourceHeaderRow: View {
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SidebarFooterView: View {
|
|
||||||
let state: SidebarFooterState
|
|
||||||
let revealAction: (URL) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if state.style == .inProgress {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(state.title)
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.foregroundStyle(primaryColor)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let subtitle = state.subtitle {
|
|
||||||
Text(subtitle)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let revealURL = state.revealURL {
|
|
||||||
Button("Reveal in Finder") {
|
|
||||||
revealAction(revealURL)
|
|
||||||
}
|
|
||||||
.buttonStyle(.link)
|
|
||||||
.font(.footnote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var primaryColor: Color {
|
|
||||||
switch state.style {
|
|
||||||
case .idle, .inProgress:
|
|
||||||
return .primary
|
|
||||||
case .failure:
|
|
||||||
return .red
|
|
||||||
case .success:
|
|
||||||
return .appAccent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ItemListColumnView<MenuContent: View>: View {
|
|
||||||
let isEmpty: Bool
|
|
||||||
@Binding var isDropTargeted: Bool
|
|
||||||
@Binding var selectedItemID: MinecraftContentItem.ID?
|
|
||||||
@Binding var searchText: String
|
|
||||||
@Binding var sortMode: ItemSortMode
|
|
||||||
let title: String
|
|
||||||
let subtitle: String
|
|
||||||
let items: [MinecraftContentItem]
|
|
||||||
let searchPrompt: String
|
|
||||||
let chooseFolderAction: () -> Void
|
|
||||||
let dropAction: ([NSItemProvider]) -> Bool
|
|
||||||
let refreshAction: () -> Void
|
|
||||||
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isEmpty {
|
|
||||||
EmptySourcesView(
|
|
||||||
isDropTargeted: isDropTargeted,
|
|
||||||
chooseFolder: chooseFolderAction
|
|
||||||
)
|
|
||||||
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction)
|
|
||||||
} else {
|
|
||||||
List(items, selection: $selectedItemID) { item in
|
|
||||||
ContentRowView(item: item)
|
|
||||||
.tag(item.id)
|
|
||||||
.contextMenu {
|
|
||||||
itemContextMenu(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.inset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $searchText, prompt: searchPrompt)
|
|
||||||
.navigationTitle(isEmpty ? "Library" : title)
|
|
||||||
.navigationSubtitle(isEmpty ? "" : subtitle)
|
|
||||||
.toolbar {
|
|
||||||
if !isEmpty {
|
|
||||||
ToolbarItemGroup {
|
|
||||||
Button(action: refreshAction) {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.help("Rescan Source")
|
|
||||||
|
|
||||||
Menu {
|
|
||||||
Picker("Sort By", selection: $sortMode) {
|
|
||||||
ForEach(ItemSortMode.allCases) { mode in
|
|
||||||
Text(mode.title).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
}
|
|
||||||
.help("List Options")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ItemDetailColumnView: View {
|
|
||||||
let item: MinecraftContentItem?
|
|
||||||
let behaviorPacks: [ContentPackReference]
|
|
||||||
let resourcePacks: [ContentPackReference]
|
|
||||||
let worldsUsingPack: [MinecraftContentItem]
|
|
||||||
let backingPackInstances: [MinecraftContentItem]
|
|
||||||
let isSuspiciousPack: Bool
|
|
||||||
let contents: [DirectoryPreviewEntry]
|
|
||||||
let directoryPreviewLimit: Int
|
|
||||||
let isEmpty: Bool
|
|
||||||
let isPerformingItemAction: Bool
|
|
||||||
let exportTitle: String?
|
|
||||||
let exportAction: () -> Void
|
|
||||||
let revealAction: () -> Void
|
|
||||||
let shareAction: (NSView?) -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
if isEmpty {
|
|
||||||
Text("Add a source folder to start scanning your Minecraft library")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else if let item {
|
|
||||||
ItemDetailView(
|
|
||||||
item: item,
|
|
||||||
behaviorPacks: behaviorPacks,
|
|
||||||
resourcePacks: resourcePacks,
|
|
||||||
worldsUsingPack: worldsUsingPack,
|
|
||||||
backingPackInstances: backingPackInstances,
|
|
||||||
isSuspiciousPack: isSuspiciousPack,
|
|
||||||
contents: contents,
|
|
||||||
directoryPreviewLimit: directoryPreviewLimit
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Select a world or pack to see details")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
if item != nil {
|
|
||||||
ToolbarItemGroup {
|
|
||||||
Button(action: exportAction) {
|
|
||||||
Image(systemName: "arrow.down.circle")
|
|
||||||
}
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
.help(exportTitle ?? "Export")
|
|
||||||
|
|
||||||
Button(action: revealAction) {
|
|
||||||
Image(systemName: "folder")
|
|
||||||
}
|
|
||||||
.disabled(isPerformingItemAction)
|
|
||||||
.help("Reveal in Finder")
|
|
||||||
|
|
||||||
SharingPickerButton(
|
|
||||||
title: nil,
|
|
||||||
systemImage: "square.and.arrow.up",
|
|
||||||
isEnabled: !isPerformingItemAction
|
|
||||||
) { anchorView in
|
|
||||||
shareAction(anchorView)
|
|
||||||
}
|
|
||||||
.help("Share")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ContentRowView: View {
|
|
||||||
let item: MinecraftContentItem
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 10) {
|
|
||||||
ItemThumbnailView(iconURL: item.iconURL)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(item.displayName)
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Text(metadataLine)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if !item.metadataLoaded || !item.sizeLoaded {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var metadataLine: String {
|
|
||||||
let sizeText: String
|
|
||||||
if let sizeBytes = item.sizeBytes {
|
|
||||||
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
|
||||||
} else if item.metadataLoaded {
|
|
||||||
sizeText = "Calculating size..."
|
|
||||||
} else {
|
|
||||||
sizeText = "Loading metadata..."
|
|
||||||
}
|
|
||||||
let dateText = item.displayDate.map {
|
|
||||||
$0.formatted(date: .abbreviated, time: .omitted)
|
|
||||||
} ?? "Date unavailable"
|
|
||||||
|
|
||||||
return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ItemDetailView: View {
|
|
||||||
let item: MinecraftContentItem
|
|
||||||
let behaviorPacks: [ContentPackReference]
|
|
||||||
let resourcePacks: [ContentPackReference]
|
|
||||||
let worldsUsingPack: [MinecraftContentItem]
|
|
||||||
let backingPackInstances: [MinecraftContentItem]
|
|
||||||
let isSuspiciousPack: Bool
|
|
||||||
let contents: [DirectoryPreviewEntry]
|
|
||||||
let directoryPreviewLimit: Int
|
|
||||||
@State private var isTechnicalDetailsExpanded = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
|
||||||
LargeItemThumbnailView(iconURL: item.iconURL, contentType: item.contentType)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(item.displayName)
|
|
||||||
.font(.largeTitle.weight(.semibold))
|
|
||||||
|
|
||||||
Text(item.contentType.rawValue)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detailCard {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
Text("Details")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if isSuspiciousPack {
|
|
||||||
Label("Manifest UUID is missing or unreadable for this pack.", systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
|
|
||||||
detailValueRow(title: "Size", value: sizeText)
|
|
||||||
detailValueRow(title: item.displayDateLabel, value: displayDateText)
|
|
||||||
|
|
||||||
if item.contentType == .world {
|
|
||||||
detailValueRow(
|
|
||||||
title: "Last Played",
|
|
||||||
value: item.lastPlayedDate?.formatted(date: .abbreviated, time: .omitted) ?? "Not available"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.contentType == .world, !behaviorPacks.isEmpty || !resourcePacks.isEmpty {
|
|
||||||
detailCard {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
Text("Packs Used")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
if !behaviorPacks.isEmpty {
|
|
||||||
packSection(title: "Behavior Packs", packs: behaviorPacks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !resourcePacks.isEmpty {
|
|
||||||
packSection(title: "Resource Packs", packs: resourcePacks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !worldsUsingPack.isEmpty {
|
|
||||||
detailCard {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
Text("Used By Worlds")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
ForEach(worldsUsingPack) { world in
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
PackReferenceIconView(iconURL: world.iconURL)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(world.displayName)
|
|
||||||
|
|
||||||
Text(worldUsageSecondaryText(for: world))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty {
|
|
||||||
detailCard {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
Text("Pack Instances")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
ForEach(backingPackInstances) { instance in
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
PackReferenceIconView(iconURL: instance.iconURL)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(instance.folderName)
|
|
||||||
|
|
||||||
Text(packInstanceSecondaryText(for: instance))
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detailCard {
|
|
||||||
DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) {
|
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
|
||||||
detailRow(title: "Folder ID", value: item.folderID)
|
|
||||||
detailRow(title: "Folder Path", value: item.folderURL.path)
|
|
||||||
detailRow(title: "Collection Root", value: item.collectionRootURL.path)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Contents")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
if contents.isEmpty {
|
|
||||||
Text("No visible files or folders")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
ForEach(contents) { entry in
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Image(systemName: entry.isDirectory ? "folder" : "doc")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(entry.name)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if contents.count == directoryPreviewLimit {
|
|
||||||
Text("Showing the first \(directoryPreviewLimit) items")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 8)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Technical Details")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
isTechnicalDetailsExpanded.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(28)
|
|
||||||
.frame(maxWidth: 450, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func detailCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
||||||
content()
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(18)
|
|
||||||
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func packSection(title: String, packs: [ContentPackReference]) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(title)
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
|
|
||||||
ForEach(packs) { pack in
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
PackReferenceIconView(iconURL: pack.iconURL)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(pack.name)
|
|
||||||
|
|
||||||
if let secondary = packSecondaryText(pack), !secondary.isEmpty {
|
|
||||||
Text(secondary)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func detailRow(title: String, value: String) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(title)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
Text(value)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func detailValueRow(title: String, value: String) -> some View {
|
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
|
||||||
Text(title)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Text(value)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sizeText: String {
|
|
||||||
if let sizeBytes = item.sizeBytes {
|
|
||||||
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
|
||||||
}
|
|
||||||
|
|
||||||
private var displayDateText: String {
|
|
||||||
item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func packSecondaryText(_ pack: ContentPackReference) -> String? {
|
|
||||||
let components = [pack.version.map { "v\($0)" }, pack.uuid]
|
|
||||||
.compactMap { $0 }
|
|
||||||
return components.isEmpty ? nil : components.joined(separator: " • ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func worldUsageSecondaryText(for world: MinecraftContentItem) -> String {
|
|
||||||
let dateText = world.displayDate?.formatted(date: .abbreviated, time: .omitted) ?? "Date unavailable"
|
|
||||||
return "\(world.displayDateLabel) \(dateText)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func packInstanceSecondaryText(for instance: MinecraftContentItem) -> String {
|
|
||||||
if instance.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) {
|
|
||||||
return "Embedded in world copy"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Top-level pack folder"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 LaunchRestoreOverlayView: View {
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
Rectangle()
|
|
||||||
.fill(.regularMaterial)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.large)
|
|
||||||
|
|
||||||
Text("Restoring Saved Library")
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
|
|
||||||
Text("Loading saved sources, metadata, and cached artwork.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.vertical, 28)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 18)
|
|
||||||
.fill(.background.opacity(0.92))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct PackReferenceIconView: View {
|
|
||||||
let iconURL: URL?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let image = loadImage(from: iconURL) {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 34, height: 34)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(.quaternary)
|
|
||||||
.frame(width: 34, height: 34)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "shippingbox")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EmptySourcesView: View {
|
|
||||||
let isDropTargeted: Bool
|
|
||||||
let chooseFolder: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 24)
|
|
||||||
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
|
||||||
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25))
|
|
||||||
.frame(width: 220, height: 160)
|
|
||||||
|
|
||||||
Image(systemName: "folder.badge.plus")
|
|
||||||
.font(.system(size: 56, weight: .regular))
|
|
||||||
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
Text("Add a Minecraft Source")
|
|
||||||
.font(.title2)
|
|
||||||
|
|
||||||
Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: 420)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Choose Minecraft Folder...") {
|
|
||||||
chooseFolder()
|
|
||||||
}
|
|
||||||
.controlSize(.large)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ItemThumbnailView: View {
|
|
||||||
let iconURL: URL?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let image = loadImage(from: iconURL) {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(.quaternary)
|
|
||||||
.frame(width: 40, height: 40)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "shippingbox")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct LargeItemThumbnailView: View {
|
|
||||||
let iconURL: URL?
|
|
||||||
let contentType: MinecraftContentType
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let image = loadImage(from: iconURL) {
|
|
||||||
Image(nsImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(image.size, contentMode: .fit)
|
|
||||||
.frame(maxWidth: 420, maxHeight: 340)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
|
||||||
} else {
|
|
||||||
RoundedRectangle(cornerRadius: 28)
|
|
||||||
.fill(.quaternary)
|
|
||||||
.frame(maxWidth: 420, minHeight: 260, maxHeight: 340)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: fallbackIconName)
|
|
||||||
.font(.system(size: 56))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fallbackIconName: 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 loadImage(from url: URL?) -> NSImage? {
|
|
||||||
guard let url else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return NSImage(contentsOf: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Color {
|
|
||||||
static let appAccent = Color("AccentColor")
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
641
World Manager for Minecraft/ItemDetailColumnViews.swift
Normal file
641
World Manager for Minecraft/ItemDetailColumnViews.swift
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DirectoryPreviewEntry: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let isDirectory: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemDetailColumnView: View {
|
||||||
|
let item: MinecraftContentItem?
|
||||||
|
let source: MinecraftSource?
|
||||||
|
let behaviorPacks: [ContentPackReference]
|
||||||
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let worldsUsingPack: [MinecraftContentItem]
|
||||||
|
let backingPackInstances: [MinecraftContentItem]
|
||||||
|
let isSuspiciousPack: Bool
|
||||||
|
let contents: [DirectoryPreviewEntry]
|
||||||
|
let directoryPreviewLimit: Int
|
||||||
|
let isEmpty: Bool
|
||||||
|
let isPerformingItemAction: Bool
|
||||||
|
let exportTitle: String?
|
||||||
|
let exportAction: () -> Void
|
||||||
|
let revealAction: () -> Void
|
||||||
|
let shareAction: (NSView?) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isEmpty {
|
||||||
|
// Text("Add a source folder to start scanning your Minecraft library")
|
||||||
|
// .foregroundStyle(.secondary)
|
||||||
|
} else if let item {
|
||||||
|
ItemDetailView(
|
||||||
|
item: item,
|
||||||
|
source: source,
|
||||||
|
behaviorPacks: behaviorPacks,
|
||||||
|
resourcePacks: resourcePacks,
|
||||||
|
worldsUsingPack: worldsUsingPack,
|
||||||
|
backingPackInstances: backingPackInstances,
|
||||||
|
isSuspiciousPack: isSuspiciousPack,
|
||||||
|
contents: contents,
|
||||||
|
directoryPreviewLimit: directoryPreviewLimit,
|
||||||
|
isPerformingItemAction: isPerformingItemAction,
|
||||||
|
exportTitle: exportTitle,
|
||||||
|
exportAction: exportAction,
|
||||||
|
revealAction: revealAction,
|
||||||
|
shareAction: shareAction
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Select a world or pack to see details")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
if item != nil {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button(action: exportAction) {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
.help(exportTitle ?? "Export")
|
||||||
|
|
||||||
|
Button(action: revealAction) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
}
|
||||||
|
.disabled(isPerformingItemAction)
|
||||||
|
.help("Reveal in Finder")
|
||||||
|
|
||||||
|
SharingPickerButton(
|
||||||
|
title: nil,
|
||||||
|
systemImage: "square.and.arrow.up",
|
||||||
|
isEnabled: !isPerformingItemAction
|
||||||
|
) { anchorView in
|
||||||
|
shareAction(anchorView)
|
||||||
|
}
|
||||||
|
.help("Share")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemDetailView: View {
|
||||||
|
private let contentMaxWidth: CGFloat = 760
|
||||||
|
|
||||||
|
let item: MinecraftContentItem
|
||||||
|
let source: MinecraftSource?
|
||||||
|
let behaviorPacks: [ContentPackReference]
|
||||||
|
let resourcePacks: [ContentPackReference]
|
||||||
|
let worldsUsingPack: [MinecraftContentItem]
|
||||||
|
let backingPackInstances: [MinecraftContentItem]
|
||||||
|
let isSuspiciousPack: Bool
|
||||||
|
let contents: [DirectoryPreviewEntry]
|
||||||
|
let directoryPreviewLimit: Int
|
||||||
|
let isPerformingItemAction: Bool
|
||||||
|
let exportTitle: String?
|
||||||
|
let exportAction: () -> Void
|
||||||
|
let revealAction: () -> Void
|
||||||
|
let shareAction: (NSView?) -> Void
|
||||||
|
@State private var storageBreakdown = StorageBreakdown.loading
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
heroSection
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
|
recordSection(title: "About") {
|
||||||
|
summaryGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
if let healthMessages, !healthMessages.isEmpty {
|
||||||
|
recordSection(title: "Compatibility") {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(healthMessages, id: \.self) { message in
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.contentType == .world, !behaviorPacks.isEmpty || !resourcePacks.isEmpty || !relationshipHighlights.isEmpty {
|
||||||
|
recordSection(title: "Packs Used") {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if !relationshipHighlights.isEmpty {
|
||||||
|
summaryLines(relationshipHighlights)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !behaviorPacks.isEmpty {
|
||||||
|
packSection(title: "Behavior Packs", packs: behaviorPacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resourcePacks.isEmpty {
|
||||||
|
packSection(title: "Resource Packs", packs: resourcePacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !worldsUsingPack.isEmpty {
|
||||||
|
recordSection(title: "Used By Worlds") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
summaryLines(packUsageHighlights)
|
||||||
|
|
||||||
|
ForEach(worldsUsingPack) { world in
|
||||||
|
recordListRow(
|
||||||
|
title: world.displayName,
|
||||||
|
subtitle: worldUsageSecondaryText(for: world),
|
||||||
|
iconURL: world.iconURL,
|
||||||
|
fallbackSystemImage: "globe.europe.africa"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty {
|
||||||
|
recordSection(title: "Pack Instances") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
summaryLines(instanceHighlights)
|
||||||
|
|
||||||
|
ForEach(backingPackInstances) { instance in
|
||||||
|
recordListRow(
|
||||||
|
title: instance.folderName,
|
||||||
|
subtitle: packInstanceSecondaryText(for: instance),
|
||||||
|
iconURL: instance.iconURL,
|
||||||
|
fallbackSystemImage: fallbackIconName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSection(title: "Storage") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
if !storageHighlights.isEmpty {
|
||||||
|
summaryLines(storageHighlights)
|
||||||
|
}
|
||||||
|
|
||||||
|
detailValueRow(title: "Primary Data", value: storageBreakdown.primaryDataText)
|
||||||
|
detailValueRow(title: "Resources", value: storageBreakdown.resourcesText)
|
||||||
|
detailValueRow(title: "Metadata", value: storageBreakdown.metadataText)
|
||||||
|
detailValueRow(title: "Artwork", value: item.iconURL == nil ? "No icon found" : "Thumbnail found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSection(title: "Locations") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
detailRow(title: "Source Library", value: source?.displayName ?? item.collectionRootURL.deletingLastPathComponent().lastPathComponent)
|
||||||
|
detailRow(title: "Record Path", value: item.folderURL.path)
|
||||||
|
detailRow(title: "Collection Root", value: item.collectionRootURL.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSection(title: "Activity") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
detailValueRow(title: "Indexed", value: source?.lastScanDate?.formatted(date: .abbreviated, time: .shortened) ?? "Unknown")
|
||||||
|
detailValueRow(title: "Visible Items", value: visibleContentsCountText)
|
||||||
|
|
||||||
|
if !contents.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Recent Folder Contents")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
ForEach(contents) { entry in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: entry.isDirectory ? "folder.fill" : "doc.text")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(entry.name)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if contents.count == directoryPreviewLimit {
|
||||||
|
Text("Showing the first \(directoryPreviewLimit) items")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSection(title: "Technical Details") {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
detailRow(title: "Folder ID", value: item.folderID)
|
||||||
|
detailRow(title: "Type", value: item.contentType.rawValue)
|
||||||
|
detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: contentMaxWidth, alignment: .leading)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.task(id: item.id) {
|
||||||
|
storageBreakdown = await loadStorageBreakdown(for: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heroSection: some View {
|
||||||
|
RecordHeroView(
|
||||||
|
item: item,
|
||||||
|
metadataChips: heroMetadata,
|
||||||
|
fallbackSystemImage: fallbackIconName,
|
||||||
|
copyAction: {
|
||||||
|
copyToPasteboard(item.displayName)
|
||||||
|
},
|
||||||
|
actionRow: AnyView(actionRow),
|
||||||
|
contentMaxWidth: contentMaxWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionRow: some View {
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
actionButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryGrid: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
detailValueRow(title: "Name", value: item.displayName)
|
||||||
|
detailValueRow(title: "Size", value: sizeText)
|
||||||
|
detailValueRow(title: item.displayDateLabel, value: displayDateText)
|
||||||
|
detailValueRow(title: "Created", value: createdDateText)
|
||||||
|
|
||||||
|
if item.contentType == .world {
|
||||||
|
detailValueRow(
|
||||||
|
title: "Pack References",
|
||||||
|
value: "\(behaviorPacks.count + resourcePacks.count)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.contentType == .behaviorPack || item.contentType == .resourcePack {
|
||||||
|
detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable")
|
||||||
|
detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var healthMessages: [String]? {
|
||||||
|
var messages: [String] = []
|
||||||
|
|
||||||
|
if isSuspiciousPack {
|
||||||
|
messages.append("Manifest UUID is missing or unreadable for this pack, so matching is using a weaker fallback identity.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unresolvedCount, unresolvedCount > 0 {
|
||||||
|
messages.append("\(unresolvedCount) referenced pack\(unresolvedCount == 1 ? "" : "s") could not be matched in this library.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.isEmpty ? nil : messages
|
||||||
|
}
|
||||||
|
|
||||||
|
private var relationshipHighlights: [String] {
|
||||||
|
guard item.contentType == .world else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var highlights: [String] = []
|
||||||
|
let totalPackCount = behaviorPacks.count + resourcePacks.count
|
||||||
|
if totalPackCount > 0 {
|
||||||
|
highlights.append("Uses \(totalPackCount) pack\(totalPackCount == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let resolvedCount {
|
||||||
|
highlights.append("\(resolvedCount) found in this library")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let unresolvedCount, unresolvedCount > 0 {
|
||||||
|
highlights.append("\(unresolvedCount) unresolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
let relatedWorldCount = otherWorldsSharingReferencedPacks
|
||||||
|
if relatedWorldCount > 0 {
|
||||||
|
highlights.append("Shared by \(relatedWorldCount) other world\(relatedWorldCount == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packUsageHighlights: [String] {
|
||||||
|
[
|
||||||
|
"\(worldsUsingPack.count) world\(worldsUsingPack.count == 1 ? "" : "s") use this",
|
||||||
|
backingPackInstances.count > 1 ? "\(backingPackInstances.count) copies indexed" : "1 indexed copy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instanceHighlights: [String] {
|
||||||
|
let embeddedCount = backingPackInstances.filter {
|
||||||
|
$0.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName)
|
||||||
|
}.count
|
||||||
|
let topLevelCount = backingPackInstances.count - embeddedCount
|
||||||
|
|
||||||
|
var highlights: [String] = []
|
||||||
|
if topLevelCount > 0 {
|
||||||
|
highlights.append("\(topLevelCount) library cop\(topLevelCount == 1 ? "y" : "ies")")
|
||||||
|
}
|
||||||
|
if embeddedCount > 0 {
|
||||||
|
highlights.append("\(embeddedCount) embedded in worlds")
|
||||||
|
}
|
||||||
|
return highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageHighlights: [String] {
|
||||||
|
var highlights = [storageFormatLabel]
|
||||||
|
if let approximateAgeText {
|
||||||
|
highlights.append(approximateAgeText)
|
||||||
|
}
|
||||||
|
return highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heroMetadata: [String] {
|
||||||
|
var chips = [item.contentType.rawValue, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
|
||||||
|
|
||||||
|
if item.contentType == .world {
|
||||||
|
let packCount = behaviorPacks.count + resourcePacks.count
|
||||||
|
if packCount > 0 {
|
||||||
|
chips.append("\(packCount) pack\(packCount == 1 ? "" : "s")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unresolvedCount: Int? {
|
||||||
|
source?.logicalWorld(forItemID: item.id)?.unresolvedReferences.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resolvedCount: Int? {
|
||||||
|
guard let logicalWorld = source?.logicalWorld(forItemID: item.id) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return logicalWorld.usedPackIDs.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var otherWorldsSharingReferencedPacks: Int {
|
||||||
|
guard
|
||||||
|
let source,
|
||||||
|
let logicalWorld = source.logicalWorld(forItemID: item.id)
|
||||||
|
else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let relatedWorldIDs = Set(logicalWorld.usedPackIDs.flatMap { packID in
|
||||||
|
source.worldsUsingPack(packID).map(\.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return max(0, relatedWorldIDs.subtracting([item.id]).count)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionRowExportTitle: String {
|
||||||
|
if exportTitle != nil {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
return "Export World"
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack:
|
||||||
|
return "Export Pack"
|
||||||
|
case .worldTemplate:
|
||||||
|
return "Export Template"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Export"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallbackIconName: String {
|
||||||
|
switch item.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 var storageFormatLabel: String {
|
||||||
|
switch item.contentType {
|
||||||
|
case .world:
|
||||||
|
return FileManager.default.fileExists(atPath: item.folderURL.appendingPathComponent("db", isDirectory: true).path)
|
||||||
|
? "LevelDB world storage"
|
||||||
|
: "Flat-file world storage"
|
||||||
|
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||||
|
return "Manifest-based package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var createdDateText: String {
|
||||||
|
(try? item.folderURL.resourceValues(forKeys: [.creationDateKey]).creationDate)?
|
||||||
|
.formatted(date: .abbreviated, time: .omitted) ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var approximateAgeText: String? {
|
||||||
|
guard let createdDate = try? item.folderURL.resourceValues(forKeys: [.creationDateKey]).creationDate else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month], from: createdDate, to: .now)
|
||||||
|
if let year = components.year, year > 0 {
|
||||||
|
return year == 1 ? "About 1 year old" : "About \(year) years old"
|
||||||
|
}
|
||||||
|
if let month = components.month, month > 0 {
|
||||||
|
return month == 1 ? "About 1 month old" : "About \(month) months old"
|
||||||
|
}
|
||||||
|
return "Recently created"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleContentsCountText: String {
|
||||||
|
if contents.isEmpty {
|
||||||
|
return "No visible files or folders"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(contents.count)\(contents.count == directoryPreviewLimit ? "+" : "") visible entries"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordSection<Content: View>(
|
||||||
|
title: String,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tracking(0.5)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(18)
|
||||||
|
.background(.quaternary.opacity(0.32), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func packSection(title: String, packs: [ContentPackReference]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
|
ForEach(packs) { pack in
|
||||||
|
recordListRow(
|
||||||
|
title: pack.name,
|
||||||
|
subtitle: packSecondaryText(pack),
|
||||||
|
iconURL: pack.iconURL,
|
||||||
|
fallbackSystemImage: pack.type == .resourcePack ? "paintpalette" : "shippingbox"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.lineLimit(3)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailValueRow(title: String, value: String) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
|
Text(title)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(3)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sizeText: String {
|
||||||
|
if let sizeBytes = item.sizeBytes {
|
||||||
|
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var displayDateText: String {
|
||||||
|
item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func packSecondaryText(_ pack: ContentPackReference) -> String? {
|
||||||
|
let components = [pack.version.map { "v\($0)" }, pack.uuid]
|
||||||
|
.compactMap { $0 }
|
||||||
|
return components.isEmpty ? nil : components.joined(separator: " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func worldUsageSecondaryText(for world: MinecraftContentItem) -> String {
|
||||||
|
let dateText = world.displayDate?.formatted(date: .abbreviated, time: .omitted) ?? "Date unavailable"
|
||||||
|
return "\(world.displayDateLabel) \(dateText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func packInstanceSecondaryText(for instance: MinecraftContentItem) -> String {
|
||||||
|
if instance.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) {
|
||||||
|
return "Embedded in world copy"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Top-level pack folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryLines(_ values: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(values, id: \.self) { value in
|
||||||
|
Label(value, systemImage: "checkmark.circle")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionButtons: some View {
|
||||||
|
ActionPillButton(
|
||||||
|
title: actionRowExportTitle,
|
||||||
|
systemImage: "arrow.down.circle.fill",
|
||||||
|
isDisabled: isPerformingItemAction,
|
||||||
|
prominence: .primary,
|
||||||
|
action: exportAction
|
||||||
|
)
|
||||||
|
|
||||||
|
ActionPillButton(
|
||||||
|
title: "Reveal",
|
||||||
|
systemImage: "folder.fill",
|
||||||
|
isDisabled: isPerformingItemAction,
|
||||||
|
prominence: .secondary,
|
||||||
|
action: revealAction
|
||||||
|
)
|
||||||
|
|
||||||
|
SharingPillButton(
|
||||||
|
title: "Share",
|
||||||
|
systemImage: "square.and.arrow.up",
|
||||||
|
isEnabled: !isPerformingItemAction,
|
||||||
|
action: shareAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func recordListRow(
|
||||||
|
title: String,
|
||||||
|
subtitle: String?,
|
||||||
|
iconURL: URL?,
|
||||||
|
fallbackSystemImage: String
|
||||||
|
) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
PackReferenceIconView(iconURL: iconURL, fallbackSystemImage: fallbackSystemImage)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if let subtitle, !subtitle.isEmpty {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadStorageBreakdown(for item: MinecraftContentItem) async -> StorageBreakdown {
|
||||||
|
await Task.detached(priority: .utility) {
|
||||||
|
StorageBreakdown.build(for: item)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemDetailColumnViews_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ItemDetailColumnPreviewContainer()
|
||||||
|
}
|
||||||
|
}
|
||||||
133
World Manager for Minecraft/ItemListColumnViews.swift
Normal file
133
World Manager for Minecraft/ItemListColumnViews.swift
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
enum ItemSortMode: String, CaseIterable, Identifiable {
|
||||||
|
case name
|
||||||
|
case modifiedDate
|
||||||
|
case size
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .name:
|
||||||
|
return "Name"
|
||||||
|
case .modifiedDate:
|
||||||
|
return "Modified Date"
|
||||||
|
case .size:
|
||||||
|
return "Size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemListColumnView<MenuContent: View>: View {
|
||||||
|
let isEmpty: Bool
|
||||||
|
@Binding var isDropTargeted: Bool
|
||||||
|
@Binding var selectedItemID: MinecraftContentItem.ID?
|
||||||
|
@Binding var searchText: String
|
||||||
|
@Binding var sortMode: ItemSortMode
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let items: [MinecraftContentItem]
|
||||||
|
let searchPrompt: String
|
||||||
|
let chooseFolderAction: () -> Void
|
||||||
|
let dropAction: ([NSItemProvider]) -> Bool
|
||||||
|
let refreshAction: () -> Void
|
||||||
|
let itemContextMenu: (MinecraftContentItem) -> MenuContent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isEmpty {
|
||||||
|
EmptySourcesView(
|
||||||
|
isDropTargeted: isDropTargeted,
|
||||||
|
chooseFolder: chooseFolderAction
|
||||||
|
)
|
||||||
|
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction)
|
||||||
|
} else {
|
||||||
|
List(items, selection: $selectedItemID) { item in
|
||||||
|
ContentRowView(item: item)
|
||||||
|
.tag(item.id)
|
||||||
|
.contextMenu {
|
||||||
|
itemContextMenu(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.inset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: searchPrompt)
|
||||||
|
.navigationTitle(isEmpty ? "Library" : title)
|
||||||
|
.navigationSubtitle(isEmpty ? "" : subtitle)
|
||||||
|
.toolbar {
|
||||||
|
if !isEmpty {
|
||||||
|
ToolbarItemGroup {
|
||||||
|
Button(action: refreshAction) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.help("Rescan Source")
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Picker("Sort By", selection: $sortMode) {
|
||||||
|
ForEach(ItemSortMode.allCases) { mode in
|
||||||
|
Text(mode.title).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.help("List Options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContentRowView: View {
|
||||||
|
let item: MinecraftContentItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
ItemThumbnailView(iconURL: item.iconURL)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.displayName)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(metadataLine)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !item.metadataLoaded || !item.sizeLoaded {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metadataLine: String {
|
||||||
|
let sizeText: String
|
||||||
|
if let sizeBytes = item.sizeBytes {
|
||||||
|
sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||||
|
} else if item.metadataLoaded {
|
||||||
|
sizeText = "Calculating size..."
|
||||||
|
} else {
|
||||||
|
sizeText = "Loading metadata..."
|
||||||
|
}
|
||||||
|
let dateText = item.displayDate.map {
|
||||||
|
$0.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
} ?? "Date unavailable"
|
||||||
|
|
||||||
|
return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemListColumnViews_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ItemListColumnPreviewContainer()
|
||||||
|
}
|
||||||
|
}
|
||||||
356
World Manager for Minecraft/PreviewFixtures.swift
Normal file
356
World Manager for Minecraft/PreviewFixtures.swift
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum PreviewFixtures {
|
||||||
|
static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000)
|
||||||
|
|
||||||
|
static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1")
|
||||||
|
static let sourceTwoURL = URL(fileURLWithPath: "/tmp/preview-library-2")
|
||||||
|
|
||||||
|
static let worldCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.world.collectionFolderName)
|
||||||
|
static let behaviorCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.behaviorPack.collectionFolderName)
|
||||||
|
static let resourceCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName)
|
||||||
|
|
||||||
|
static let behaviorPackIdentity = PackIdentity(
|
||||||
|
type: .behaviorPack,
|
||||||
|
uuid: "874117c4-3f9c-432e-bd64-da7336313337",
|
||||||
|
version: "3.0.0",
|
||||||
|
fallbackName: "Wither Storm Behavior",
|
||||||
|
fallbackLocationHint: behaviorCollectionURL.lastPathComponent
|
||||||
|
)
|
||||||
|
|
||||||
|
static let resourcePackIdentity = PackIdentity(
|
||||||
|
type: .resourcePack,
|
||||||
|
uuid: "823cbe13-eac7-49a4-b9cd-1dd25ab1048a",
|
||||||
|
version: "3.0.0",
|
||||||
|
fallbackName: "Wither Storm Resources",
|
||||||
|
fallbackLocationHint: resourceCollectionURL.lastPathComponent
|
||||||
|
)
|
||||||
|
|
||||||
|
static let behaviorPackReference = ContentPackReference(
|
||||||
|
name: "§5Cracker's Wither Storm Mod",
|
||||||
|
type: .behaviorPack,
|
||||||
|
uuid: behaviorPackIdentity.uuid,
|
||||||
|
version: behaviorPackIdentity.version,
|
||||||
|
source: .referencedByWorld
|
||||||
|
)
|
||||||
|
|
||||||
|
static let resourcePackReference = ContentPackReference(
|
||||||
|
name: "§5Cracker's Wither Storm Mod",
|
||||||
|
type: .resourcePack,
|
||||||
|
uuid: resourcePackIdentity.uuid,
|
||||||
|
version: resourcePackIdentity.version,
|
||||||
|
source: .referencedByWorld
|
||||||
|
)
|
||||||
|
|
||||||
|
static let featuredWorld = MinecraftContentItem(
|
||||||
|
folderURL: worldCollectionURL.appendingPathComponent("world-alpha"),
|
||||||
|
folderName: "world-alpha",
|
||||||
|
contentType: .world,
|
||||||
|
collectionRootURL: worldCollectionURL,
|
||||||
|
displayName: "THE ABSOLUTELY ENORMOUS KID CHAOS LAB WITH 9999 TNT AND A SECRET LLAMA BUNKER v2.0",
|
||||||
|
lastPlayedDate: baseDate.addingTimeInterval(-86_400 * 3),
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 2),
|
||||||
|
sizeBytes: 35_500_000,
|
||||||
|
packReferences: [behaviorPackReference, resourcePackReference],
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let siblingWorld = MinecraftContentItem(
|
||||||
|
folderURL: worldCollectionURL.appendingPathComponent("world-beta"),
|
||||||
|
folderName: "world-beta",
|
||||||
|
contentType: .world,
|
||||||
|
collectionRootURL: worldCollectionURL,
|
||||||
|
displayName: "Sky Battle Arena",
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 14),
|
||||||
|
sizeBytes: 14_200_000,
|
||||||
|
packReferences: [resourcePackReference],
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let archiveWorld = MinecraftContentItem(
|
||||||
|
folderURL: worldCollectionURL.appendingPathComponent("world-gamma"),
|
||||||
|
folderName: "world-gamma",
|
||||||
|
contentType: .world,
|
||||||
|
collectionRootURL: worldCollectionURL,
|
||||||
|
displayName: "Grandma's Survival Backup But Everyone Has Netherite",
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 60),
|
||||||
|
sizeBytes: 227_500_000,
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let behaviorPackItem = MinecraftContentItem(
|
||||||
|
folderURL: behaviorCollectionURL.appendingPathComponent("wither-storm-bp"),
|
||||||
|
folderName: "wither-storm-bp",
|
||||||
|
contentType: .behaviorPack,
|
||||||
|
collectionRootURL: behaviorCollectionURL,
|
||||||
|
displayName: "§5Cracker's Wither Storm Mod",
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 6),
|
||||||
|
sizeBytes: 5_300_000,
|
||||||
|
packUUID: behaviorPackIdentity.uuid,
|
||||||
|
packVersion: behaviorPackIdentity.version,
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let resourcePackItem = MinecraftContentItem(
|
||||||
|
folderURL: resourceCollectionURL.appendingPathComponent("wither-storm-rp"),
|
||||||
|
folderName: "wither-storm-rp",
|
||||||
|
contentType: .resourcePack,
|
||||||
|
collectionRootURL: resourceCollectionURL,
|
||||||
|
displayName: "§5Cracker's Wither Storm Mod",
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 6),
|
||||||
|
sizeBytes: 8_900_000,
|
||||||
|
packUUID: resourcePackIdentity.uuid,
|
||||||
|
packVersion: resourcePackIdentity.version,
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let secondLibraryPack = MinecraftContentItem(
|
||||||
|
folderURL: sourceTwoURL
|
||||||
|
.appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName)
|
||||||
|
.appendingPathComponent("cartoon-rp"),
|
||||||
|
folderName: "cartoon-rp",
|
||||||
|
contentType: .resourcePack,
|
||||||
|
collectionRootURL: sourceTwoURL.appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName),
|
||||||
|
displayName: "Cartoon Blocks Remix",
|
||||||
|
modifiedDate: baseDate.addingTimeInterval(-86_400 * 11),
|
||||||
|
sizeBytes: 12_700_000,
|
||||||
|
packUUID: "246df391-7dd1-4df6-b7aa-308a94a85d81",
|
||||||
|
packVersion: "1.4.2",
|
||||||
|
metadataLoaded: true,
|
||||||
|
sizeLoaded: true
|
||||||
|
)
|
||||||
|
|
||||||
|
static let primarySource: MinecraftSource = {
|
||||||
|
var source = MinecraftSource(folderURL: sourceOneURL)
|
||||||
|
source.displayName = "Kid iPad Imports"
|
||||||
|
source.displayItems = [
|
||||||
|
featuredWorld,
|
||||||
|
siblingWorld,
|
||||||
|
archiveWorld,
|
||||||
|
behaviorPackItem,
|
||||||
|
resourcePackItem
|
||||||
|
]
|
||||||
|
source.rawItems = source.displayItems
|
||||||
|
source.logicalPacks = [
|
||||||
|
LogicalPack(
|
||||||
|
id: behaviorPackIdentity,
|
||||||
|
contentType: .behaviorPack,
|
||||||
|
displayName: behaviorPackItem.displayName,
|
||||||
|
uuid: behaviorPackItem.packUUID,
|
||||||
|
version: behaviorPackItem.packVersion,
|
||||||
|
representativeItemID: behaviorPackItem.id,
|
||||||
|
instanceItemIDs: [behaviorPackItem.id],
|
||||||
|
isSuspicious: false
|
||||||
|
),
|
||||||
|
LogicalPack(
|
||||||
|
id: resourcePackIdentity,
|
||||||
|
contentType: .resourcePack,
|
||||||
|
displayName: resourcePackItem.displayName,
|
||||||
|
uuid: resourcePackItem.packUUID,
|
||||||
|
version: resourcePackItem.packVersion,
|
||||||
|
representativeItemID: resourcePackItem.id,
|
||||||
|
instanceItemIDs: [resourcePackItem.id],
|
||||||
|
isSuspicious: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
source.logicalWorlds = [
|
||||||
|
LogicalWorld(
|
||||||
|
id: featuredWorld.id,
|
||||||
|
itemID: featuredWorld.id,
|
||||||
|
usedPackIDs: [behaviorPackIdentity, resourcePackIdentity],
|
||||||
|
unresolvedReferences: []
|
||||||
|
),
|
||||||
|
LogicalWorld(
|
||||||
|
id: siblingWorld.id,
|
||||||
|
itemID: siblingWorld.id,
|
||||||
|
usedPackIDs: [resourcePackIdentity],
|
||||||
|
unresolvedReferences: []
|
||||||
|
)
|
||||||
|
]
|
||||||
|
source.packInstances = [
|
||||||
|
PackInstance(
|
||||||
|
id: behaviorPackItem.id,
|
||||||
|
itemID: behaviorPackItem.id,
|
||||||
|
sourceID: source.id,
|
||||||
|
logicalPackID: behaviorPackIdentity,
|
||||||
|
origin: .foundInCollection,
|
||||||
|
hostWorldItemID: nil
|
||||||
|
),
|
||||||
|
PackInstance(
|
||||||
|
id: resourcePackItem.id,
|
||||||
|
itemID: resourcePackItem.id,
|
||||||
|
sourceID: source.id,
|
||||||
|
logicalPackID: resourcePackIdentity,
|
||||||
|
origin: .foundInCollection,
|
||||||
|
hostWorldItemID: nil
|
||||||
|
)
|
||||||
|
]
|
||||||
|
source.worldPackRelationships = [
|
||||||
|
WorldPackRelationship(
|
||||||
|
worldItemID: featuredWorld.id,
|
||||||
|
logicalPackID: behaviorPackIdentity,
|
||||||
|
reference: behaviorPackReference
|
||||||
|
),
|
||||||
|
WorldPackRelationship(
|
||||||
|
worldItemID: featuredWorld.id,
|
||||||
|
logicalPackID: resourcePackIdentity,
|
||||||
|
reference: resourcePackReference
|
||||||
|
),
|
||||||
|
WorldPackRelationship(
|
||||||
|
worldItemID: siblingWorld.id,
|
||||||
|
logicalPackID: resourcePackIdentity,
|
||||||
|
reference: resourcePackReference
|
||||||
|
)
|
||||||
|
]
|
||||||
|
source.indexedItemCount = source.displayItems.count
|
||||||
|
source.indexedDetailCount = source.displayItems.count
|
||||||
|
source.lastScanDate = baseDate
|
||||||
|
return source
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let secondarySource: MinecraftSource = {
|
||||||
|
var source = MinecraftSource(folderURL: sourceTwoURL)
|
||||||
|
source.displayName = "Downloads"
|
||||||
|
source.displayItems = [secondLibraryPack]
|
||||||
|
source.rawItems = source.displayItems
|
||||||
|
source.indexedItemCount = source.displayItems.count
|
||||||
|
source.indexedDetailCount = source.displayItems.count
|
||||||
|
source.lastScanDate = baseDate.addingTimeInterval(-3_600)
|
||||||
|
return source
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let allSources = [primarySource, secondarySource]
|
||||||
|
|
||||||
|
static let sidebarFooter = SidebarFooterState(
|
||||||
|
style: .success,
|
||||||
|
title: "Export Complete",
|
||||||
|
subtitle: "Saved a preview copy of \(featuredWorld.displayName)",
|
||||||
|
revealURL: featuredWorld.folderURL
|
||||||
|
)
|
||||||
|
|
||||||
|
static let directoryEntries = [
|
||||||
|
DirectoryPreviewEntry(name: "db", isDirectory: true),
|
||||||
|
DirectoryPreviewEntry(name: "level.dat", isDirectory: false),
|
||||||
|
DirectoryPreviewEntry(name: "levelname.txt", isDirectory: false),
|
||||||
|
DirectoryPreviewEntry(name: "world_icon.jpeg", isDirectory: false),
|
||||||
|
DirectoryPreviewEntry(name: "resource_packs", isDirectory: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
nonisolated static func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
|
||||||
|
let allFilter = SidebarFilter(
|
||||||
|
title: "All Content",
|
||||||
|
iconName: "square.stack.3d.up",
|
||||||
|
count: source.items.count,
|
||||||
|
selection: .allContent(sourceID: source.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
let groupedFilters = MinecraftContentType.allCases.compactMap { contentType -> SidebarFilter? in
|
||||||
|
let count = source.items.filter { $0.contentType == contentType }.count
|
||||||
|
guard count > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SidebarFilter(
|
||||||
|
title: contentType.rawValue,
|
||||||
|
iconName: iconName(for: contentType),
|
||||||
|
count: count,
|
||||||
|
selection: .contentType(sourceID: source.id, contentType: contentType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [allFilter] + groupedFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func iconName(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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SidebarColumnPreviewContainer: View {
|
||||||
|
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
SourcesSidebarView(
|
||||||
|
sources: PreviewFixtures.allSources,
|
||||||
|
selection: $selection,
|
||||||
|
footerState: PreviewFixtures.sidebarFooter,
|
||||||
|
addSourceAction: {},
|
||||||
|
rescanSourceAction: { _ in },
|
||||||
|
removeSourceAction: { _ in },
|
||||||
|
revealFooterURLAction: { _ in },
|
||||||
|
filters: PreviewFixtures.sidebarFilters(for:)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemListColumnPreviewContainer: View {
|
||||||
|
@State private var isDropTargeted = false
|
||||||
|
@State private var selectedItemID: MinecraftContentItem.ID? = PreviewFixtures.featuredWorld.id
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var sortMode: ItemSortMode = .modifiedDate
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ItemListColumnView(
|
||||||
|
isEmpty: false,
|
||||||
|
isDropTargeted: $isDropTargeted,
|
||||||
|
selectedItemID: $selectedItemID,
|
||||||
|
searchText: $searchText,
|
||||||
|
sortMode: $sortMode,
|
||||||
|
title: "All Items",
|
||||||
|
subtitle: "5 items in Kid iPad Imports",
|
||||||
|
items: PreviewFixtures.primarySource.displayItems,
|
||||||
|
searchPrompt: "Search Worlds",
|
||||||
|
chooseFolderAction: {},
|
||||||
|
dropAction: { _ in false },
|
||||||
|
refreshAction: {},
|
||||||
|
itemContextMenu: { item in
|
||||||
|
Button("Reveal \(item.displayName)") {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemDetailColumnPreviewContainer: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ItemDetailColumnView(
|
||||||
|
item: PreviewFixtures.featuredWorld,
|
||||||
|
source: PreviewFixtures.primarySource,
|
||||||
|
behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack),
|
||||||
|
resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack),
|
||||||
|
worldsUsingPack: [],
|
||||||
|
backingPackInstances: [],
|
||||||
|
isSuspiciousPack: false,
|
||||||
|
contents: PreviewFixtures.directoryEntries,
|
||||||
|
directoryPreviewLimit: 12,
|
||||||
|
isEmpty: false,
|
||||||
|
isPerformingItemAction: false,
|
||||||
|
exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle,
|
||||||
|
exportAction: {},
|
||||||
|
revealAction: {},
|
||||||
|
shareAction: { _ in }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
World Manager for Minecraft/SidebarColumnViews.swift
Normal file
180
World Manager for Minecraft/SidebarColumnViews.swift
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SidebarSelection: Hashable {
|
||||||
|
case allContent(sourceID: URL)
|
||||||
|
case contentType(sourceID: URL, contentType: MinecraftContentType)
|
||||||
|
|
||||||
|
var sourceID: URL {
|
||||||
|
switch self {
|
||||||
|
case .allContent(let sourceID), .contentType(let sourceID, _):
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SidebarFilter: Identifiable, Hashable {
|
||||||
|
var id: SidebarSelection { selection }
|
||||||
|
let title: String
|
||||||
|
let iconName: String
|
||||||
|
let count: Int
|
||||||
|
let selection: SidebarSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourcesSidebarView: View {
|
||||||
|
let sources: [MinecraftSource]
|
||||||
|
@Binding var selection: SidebarSelection?
|
||||||
|
let footerState: SidebarFooterState
|
||||||
|
let addSourceAction: () -> Void
|
||||||
|
let rescanSourceAction: (MinecraftSource) -> Void
|
||||||
|
let removeSourceAction: (MinecraftSource) -> Void
|
||||||
|
let revealFooterURLAction: (URL) -> Void
|
||||||
|
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $selection) {
|
||||||
|
Section {
|
||||||
|
ForEach(sources) { source in
|
||||||
|
SourceHeaderRow(title: source.displayName)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
|
rescanSourceAction(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
|
removeSourceAction(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(filters(source)) { filter in
|
||||||
|
SidebarFilterRow(filter: filter, isIndented: true)
|
||||||
|
.tag(filter.selection as SidebarSelection?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
SidebarSourcesSectionHeaderView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if footerState.style != .idle {
|
||||||
|
SidebarFooterView(
|
||||||
|
state: footerState,
|
||||||
|
revealAction: revealFooterURLAction
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem {
|
||||||
|
Button(action: addSourceAction) {
|
||||||
|
Image(systemName: "folder.badge.plus")
|
||||||
|
}
|
||||||
|
.help("Add Source Folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarFilterRow: View {
|
||||||
|
let filter: SidebarFilter
|
||||||
|
let isIndented: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: filter.iconName)
|
||||||
|
.frame(width: 16)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(filter.title)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(filter.count, format: .number)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.leading, isIndented ? 16 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarSourcesSectionHeaderView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Libraries")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SourceHeaderRow: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarFooterView: View {
|
||||||
|
let state: SidebarFooterState
|
||||||
|
let revealAction: (URL) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if state.style == .inProgress {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(state.title)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(primaryColor)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let subtitle = state.subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let revealURL = state.revealURL {
|
||||||
|
Button("Reveal in Finder") {
|
||||||
|
revealAction(revealURL)
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var primaryColor: Color {
|
||||||
|
switch state.style {
|
||||||
|
case .idle, .inProgress:
|
||||||
|
return .primary
|
||||||
|
case .failure:
|
||||||
|
return .red
|
||||||
|
case .success:
|
||||||
|
return .appAccent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SidebarColumnViews_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
SidebarColumnPreviewContainer()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user