import AppKit import SwiftUI import MacToolsPluginKit // MARK: - Window @MainActor final class XcodeCleanConfirmWindow: NSPanel { private let viewModel: XcodeCleanConfirmViewModel private var onDismiss: (() -> Void)? init( candidates: [XcodeCleanCandidate], localization: PluginLocalization = PluginLocalization(bundle: .main), onConfirm: @escaping (Set) -> Void, onCancel: @escaping () -> Void ) { let size = NSSize(width: 480, height: 540) self.viewModel = XcodeCleanConfirmViewModel(candidates: candidates, localization: localization) super.init( contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) isFloatingPanel = true level = .floating backgroundColor = .clear isOpaque = false hasShadow = true isMovableByWindowBackground = true collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] let effectView = NSVisualEffectView(frame: NSRect(origin: .zero, size: size)) effectView.material = .sidebar effectView.blendingMode = .behindWindow effectView.state = .active effectView.maskImage = Self.roundedMaskImage(size: size, cornerRadius: 16) let rootView = XcodeCleanConfirmView( viewModel: viewModel, localization: localization, onConfirm: { [weak self] selectedIDs in onConfirm(selectedIDs) self?.orderOut(nil) self?.onDismiss?() }, onCancel: { [weak self] in onCancel() self?.orderOut(nil) self?.onDismiss?() } ) let hostingView = NSHostingView(rootView: rootView) hostingView.frame = effectView.bounds hostingView.autoresizingMask = [.width, .height] effectView.addSubview(hostingView) contentView = effectView setContentSize(size) } func attachDismissHandler(_ handler: @escaping () -> Void) { onDismiss = handler } override func cancelOperation(_ sender: Any?) { orderOut(nil) onDismiss?() } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } private static func roundedMaskImage(size: NSSize, cornerRadius: CGFloat) -> NSImage { let image = NSImage(size: size, flipped: false) { rect in NSColor.black.setFill() NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius).fill() return true } image.capInsets = NSEdgeInsets( top: cornerRadius, left: cornerRadius, bottom: cornerRadius, right: cornerRadius ) image.resizingMode = .stretch return image } } // MARK: - View Model @MainActor final class XcodeCleanConfirmViewModel: ObservableObject { struct Section: Identifiable { let id: XcodeCleanCategory let title: String let candidates: [XcodeCleanCandidate] } @Published private(set) var sections: [Section] @Published private(set) var selectedIDs: Set private let allCleanableIDs: Set init( candidates: [XcodeCleanCandidate], localization: PluginLocalization = PluginLocalization(bundle: .main) ) { let cleanable = candidates.filter { $0.safety.isCleanable } self.allCleanableIDs = Set(cleanable.map(\.id)) self.selectedIDs = self.allCleanableIDs var grouped: [XcodeCleanCategory: [XcodeCleanCandidate]] = [:] for candidate in cleanable { grouped[candidate.category, default: []].append(candidate) } self.sections = XcodeCleanCategory.allCases.compactMap { category in guard let items = grouped[category], !items.isEmpty else { return nil } return Section( id: category, title: category.title(localization: localization), candidates: items.sorted { $0.sizeBytes > $1.sizeBytes } ) } } var totalCount: Int { allCleanableIDs.count } var selectedCount: Int { selectedIDs.count } var selectedSizeBytes: Int64 { sections.reduce(Int64(0)) { partial, section in partial + section.candidates.reduce(Int64(0)) { sum, candidate in selectedIDs.contains(candidate.id) ? sum + max(candidate.sizeBytes, 0) : sum } } } var allSelected: Bool { selectedIDs == allCleanableIDs } func toggle(id: XcodeCleanCandidate.ID) { if selectedIDs.contains(id) { selectedIDs.remove(id) } else { selectedIDs.insert(id) } } func setSection(_ category: XcodeCleanCategory, selected: Bool) { guard let section = sections.first(where: { $0.id == category }) else { return } for candidate in section.candidates { if selected { selectedIDs.insert(candidate.id) } else { selectedIDs.remove(candidate.id) } } } func sectionState(_ category: XcodeCleanCategory) -> SectionSelectionState { guard let section = sections.first(where: { $0.id == category }) else { return .none } let selected = section.candidates.filter { selectedIDs.contains($0.id) }.count if selected == 0 { return .none } if selected == section.candidates.count { return .all } return .partial } func toggleAll() { if allSelected { selectedIDs.removeAll() } else { selectedIDs = allCleanableIDs } } } enum SectionSelectionState { case none case partial case all } // MARK: - SwiftUI View private struct XcodeCleanConfirmView: View { @ObservedObject var viewModel: XcodeCleanConfirmViewModel let localization: PluginLocalization let onConfirm: (Set) -> Void let onCancel: () -> Void var body: some View { VStack(spacing: 0) { header Divider().opacity(0.5) candidateList Divider().opacity(0.5) footer } } private var header: some View { HStack(spacing: 10) { Image(systemName: "trash") .font(.system(size: 16, weight: .medium)) .foregroundStyle(Color(nsColor: .systemBlue)) VStack(alignment: .leading, spacing: 2) { Text(localization.string("confirm.title", defaultValue: "确认清理 Xcode 缓存")) .font(.system(size: 15, weight: .semibold)) Text(headerSubtitle) .font(.caption) .foregroundStyle(.secondary) } Spacer() Button(action: { viewModel.toggleAll() }) { Text(viewModel.allSelected ? localization.string("confirm.action.deselectAll", defaultValue: "全不选") : localization.string("confirm.action.selectAll", defaultValue: "全选") ) .font(.system(size: 12)) .foregroundStyle(Color.accentColor) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) } private var headerSubtitle: String { if viewModel.totalCount == 0 { return localization.string("confirm.noItems", defaultValue: "没有可清理项目") } return localization.format( "confirm.subtitle.selected", defaultValue: "已选 %d / %d 项 · %@", viewModel.selectedCount, viewModel.totalCount, byteText(viewModel.selectedSizeBytes) ) } private var candidateList: some View { ScrollView { if viewModel.sections.isEmpty { emptyState } else { LazyVStack(alignment: .leading, spacing: 0) { ForEach(viewModel.sections) { section in sectionHeader(for: section) ForEach(section.candidates) { candidate in candidateRow(candidate) .padding(.leading, 22) Divider().opacity(0.3) .padding(.leading, 22) } } } .padding(.horizontal, 16) .padding(.vertical, 8) } } .frame(maxHeight: .infinity) } private var emptyState: some View { VStack(spacing: 8) { Spacer(minLength: 60) Image(systemName: "checkmark.circle") .font(.system(size: 32)) .foregroundStyle(.secondary) Text(localization.string("confirm.noItems", defaultValue: "没有可清理项目")) .font(.subheadline) .foregroundStyle(.secondary) Spacer(minLength: 60) } .frame(maxWidth: .infinity) } private func sectionHeader(for section: XcodeCleanConfirmViewModel.Section) -> some View { let state = viewModel.sectionState(section.id) return HStack(spacing: 8) { Button(action: { viewModel.setSection(section.id, selected: state != .all) }) { Image(systemName: sectionToggleIcon(state)) .font(.system(size: 14, weight: .regular)) .foregroundStyle(state == .none ? Color.secondary : Color.accentColor) } .buttonStyle(.plain) Text(section.title) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) Spacer() Text(itemCountText(section.candidates.count)) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 8) } private func sectionToggleIcon(_ state: SectionSelectionState) -> String { switch state { case .all: return "checkmark.square.fill" case .partial: return "minus.square.fill" case .none: return "square" } } private func candidateRow(_ candidate: XcodeCleanCandidate) -> some View { Button(action: { viewModel.toggle(id: candidate.id) }) { HStack(alignment: .top, spacing: 10) { Image(systemName: viewModel.selectedIDs.contains(candidate.id) ? "checkmark.square.fill" : "square") .font(.system(size: 14)) .foregroundStyle(viewModel.selectedIDs.contains(candidate.id) ? Color.accentColor : Color.secondary) .frame(width: 16, height: 16) VStack(alignment: .leading, spacing: 2) { Text((candidate.path as NSString).lastPathComponent) .font(.system(size: 12, weight: .medium)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.middle) Text(candidate.path) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) } Spacer(minLength: 8) Text(byteText(candidate.sizeBytes)) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundStyle(.secondary) .frame(minWidth: 64, alignment: .trailing) } .padding(.vertical, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) } private var footer: some View { VStack(spacing: 6) { Button(action: { onConfirm(viewModel.selectedIDs) }) { Text(confirmTitle) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: 10) .fill( LinearGradient( colors: [Color(nsColor: .systemBlue).opacity(0.85), Color(nsColor: .systemBlue)], startPoint: .top, endPoint: .bottom ) ) ) .overlay( RoundedRectangle(cornerRadius: 10) .strokeBorder(Color.white.opacity(0.18), lineWidth: 1) ) .opacity(viewModel.selectedCount == 0 ? 0.4 : 1) } .buttonStyle(.plain) .disabled(viewModel.selectedCount == 0) Button(action: onCancel) { Text(localization.string("confirm.action.cancel", defaultValue: "取消")) .font(.system(size: 11)) .foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) } private var confirmTitle: String { if viewModel.selectedCount == 0 { return localization.string("confirm.action.empty", defaultValue: "请选择要清理的项目") } return localization.format( "confirm.action.confirm", defaultValue: "清理 %d 项 · %@", viewModel.selectedCount, byteText(viewModel.selectedSizeBytes) ) } private func byteText(_ bytes: Int64) -> String { XcodeCleanByteFormatter.string(fromByteCount: bytes) } private func itemCountText(_ count: Int) -> String { localization.format("confirm.itemCount", defaultValue: "%d 项", count) } }