415 lines
14 KiB
Swift
415 lines
14 KiB
Swift
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<XcodeCleanCandidate.ID>) -> 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<XcodeCleanCandidate.ID>
|
|
|
|
private let allCleanableIDs: Set<XcodeCleanCandidate.ID>
|
|
|
|
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<XcodeCleanCandidate.ID>) -> 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)
|
|
}
|
|
}
|