Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

399 lines
16 KiB
Swift

import SwiftUI
import MacToolsPluginKit
struct XcodeCleanDetailView: View {
@ObservedObject var controller: XcodeCleanController
private let localization: PluginLocalization
private let showsHeader: Bool
private let contentPadding: CGFloat
private let minimumContentHeight: CGFloat
init(
controller: XcodeCleanController,
localization: PluginLocalization = PluginLocalization(bundle: .main),
showsHeader: Bool = true,
contentPadding: CGFloat = 20,
minimumContentHeight: CGFloat = 420
) {
self.controller = controller
self.localization = localization
self.showsHeader = showsHeader
self.contentPadding = contentPadding
self.minimumContentHeight = minimumContentHeight
}
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
if showsHeader {
header
}
if snapshot.isXcodeRunning {
xcodeRunningBanner
}
categoryControls
actionBar
scanLog
statusSummary
candidateList
}
.padding(contentPadding)
.frame(maxWidth: .infinity, minHeight: minimumContentHeight, alignment: .topLeading)
}
private var snapshot: XcodeCleanSnapshot { controller.snapshot }
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(localization.string("detail.title", defaultValue: "Xcode 清理"))
.font(PluginSettingsTheme.Typography.pageTitle)
Text(snapshot.errorMessage ?? subtitleText)
.font(PluginSettingsTheme.Typography.pageDescription.weight(.medium))
.foregroundStyle(snapshot.errorMessage == nil ? Color.secondary : Color.red)
}
}
private var subtitleText: String {
if snapshot.isXcodeRunning {
return localization.string("detail.subtitle.xcodeRunning", defaultValue: "请先退出 Xcode,再进行扫描或清理")
}
switch snapshot.phase {
case .idle:
return localization.string("detail.subtitle.idle", defaultValue: "选择需要清理的分类,然后点击扫描")
case .scanning:
return localization.string("detail.subtitle.scanning", defaultValue: "正在扫描…")
case .scanned:
if snapshot.isResultStale {
return localization.string("detail.subtitle.stale", defaultValue: "勾选已更新,请重新扫描")
}
return snapshot.scanResult.map {
localization.format(
"detail.subtitle.scanned",
defaultValue: "已找到 %d 项可清理",
$0.cleanableCandidates.count
)
} ?? localization.string("detail.subtitle.scanComplete", defaultValue: "扫描完成")
case .cleaning:
return localization.string("detail.subtitle.cleaning", defaultValue: "正在清理…")
case .completed:
return snapshot.executionResult.map {
localization.format(
"detail.subtitle.completed",
defaultValue: "已释放 %@",
byteText($0.reclaimedBytes)
)
} ?? localization.string("detail.subtitle.cleanComplete", defaultValue: "清理完成")
}
}
private var xcodeRunningBanner: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(localization.string("detail.xcodeRunning.title", defaultValue: "Xcode 当前正在运行"))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Text(localization.string(
"detail.xcodeRunning.description",
defaultValue: "为避免破坏正在进行的构建或索引,请先退出 Xcode 再使用此功能。"
))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.recessed)
}
private var categoryControls: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
Label(localization.string("detail.categories.title", defaultValue: "清理分类"), systemImage: "square.grid.2x2")
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
ForEach(XcodeCleanCategory.allCases) { category in
categoryRow(category)
}
}
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.rowVertical)
.pluginSettingsCardBackground(.host)
}
}
private func categoryRow(_ category: XcodeCleanCategory) -> some View {
let isSelected = snapshot.selectedCategories.contains(category)
let summary = snapshot.scanResult?.summary(for: category)
let sizeText = summary.map { byteText($0.totalBytes) } ?? "—"
return HStack(alignment: .firstTextBaseline, spacing: 10) {
Toggle(isOn: Binding(
get: { isSelected },
set: { controller.setCategory(category, isSelected: $0) }
)) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(category.title(localization: localization))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
if category.risk == .medium {
Text(localization.string("detail.risk.medium", defaultValue: "谨慎"))
.font(PluginSettingsTheme.Typography.statusBadge)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Color.orange.opacity(0.15), in: Capsule())
.foregroundStyle(.orange)
}
}
Text(category.summary(localization: localization))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
}
.toggleStyle(.checkbox)
.disabled(snapshot.isBusy || snapshot.isXcodeRunning)
Spacer(minLength: 12)
Text(sizeText)
.font(PluginSettingsTheme.Typography.monospacedValue)
.foregroundStyle(.secondary)
.frame(minWidth: 72, alignment: .trailing)
}
.padding(.vertical, 4)
}
private var actionBar: some View {
HStack(spacing: 10) {
Button {
controller.scan()
} label: {
Label(localization.string("detail.action.scan", defaultValue: "扫描"), systemImage: "magnifyingglass")
}
.disabled(!snapshot.canScan)
Button {
controller.cleanSelected(candidateIDs: cleanableCandidateIDs)
} label: {
Label(localization.string("detail.action.clean", defaultValue: "清理"), systemImage: "trash")
}
.disabled(!snapshot.canClean)
if snapshot.isBusy {
Button {
controller.cancelCurrentOperation()
} label: {
Label(localization.string("detail.action.stop", defaultValue: "停止"), systemImage: "xmark.circle")
}
}
Spacer()
}
}
private var scanLog: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(localization.string("detail.scanLog.title", defaultValue: "扫描日志"))
.font(PluginSettingsTheme.Typography.sectionTitle)
.foregroundStyle(.secondary)
Spacer()
if snapshot.phase == .scanning {
ProgressView()
.controlSize(.small)
.scaleEffect(0.75)
}
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 6) {
if snapshot.scanLogEntries.isEmpty {
Text(localization.string("detail.scanLog.empty", defaultValue: "扫描后这里会显示实时进度"))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ForEach(snapshot.scanLogEntries) { entry in
logRow(entry).id(entry.id)
}
}
}
.padding(10)
}
.frame(minHeight: 118, maxHeight: 160)
.pluginSettingsCardBackground(.recessed)
.onChange(of: snapshot.scanLogEntries.last?.id) {
guard let id = snapshot.scanLogEntries.last?.id else { return }
proxy.scrollTo(id, anchor: .bottom)
}
}
}
}
private func logRow(_ entry: XcodeCleanScanLogEntry) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: logIconName(entry.tone))
.font(PluginSettingsTheme.Typography.statusBadge)
.foregroundStyle(logColor(entry.tone))
.frame(width: 14, height: 14)
Text(entry.text)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.lineLimit(3)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var statusSummary: some View {
if let scanResult = snapshot.scanResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.cleanable", defaultValue: "可清理"),
value: itemCountText(scanResult.cleanableCandidates.count)
)
summaryTile(
title: localization.string("detail.summary.estimatedRelease", defaultValue: "预计释放"),
value: byteText(scanResult.cleanableSizeBytes)
)
summaryTile(
title: localization.string("detail.summary.protected", defaultValue: "已保护"),
value: itemCountText(scanResult.protectedCount)
)
}
}
if let executionResult = snapshot.executionResult {
HStack(spacing: 16) {
summaryTile(
title: localization.string("detail.summary.removed", defaultValue: "已删除"),
value: itemCountText(executionResult.removedCount)
)
summaryTile(
title: localization.string("detail.summary.skipped", defaultValue: "已跳过"),
value: itemCountText(executionResult.skippedCount)
)
summaryTile(
title: localization.string("detail.summary.failed", defaultValue: "失败"),
value: itemCountText(executionResult.failedCount)
)
summaryTile(
title: localization.string("detail.summary.released", defaultValue: "已释放"),
value: byteText(executionResult.reclaimedBytes)
)
}
}
}
private func summaryTile(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
Text(value)
.font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold))
}
.frame(minWidth: 96, alignment: .leading)
}
@ViewBuilder
private var candidateList: some View {
if let scanResult = snapshot.scanResult, !scanResult.candidates.isEmpty {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(scanResult.candidates) { candidate in
candidateRow(candidate)
Divider()
}
}
}
}
}
private func candidateRow(_ candidate: XcodeCleanCandidate) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: candidate.safety.isCleanable ? "checkmark.circle.fill" : "shield.fill")
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
.frame(width: 18)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(candidate.category.title(localization: localization))
.font(PluginSettingsTheme.Typography.emphasizedRowTitle)
Spacer(minLength: 12)
Text(byteText(candidate.sizeBytes))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(.secondary)
}
Text(candidate.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
Text(safetyText(candidate.safety))
.font(PluginSettingsTheme.Typography.rowDescription)
.foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary)
}
}
.padding(.vertical, 9)
}
// MARK: - Helpers
private var cleanableCandidateIDs: Set<XcodeCleanCandidate.ID> {
Set(snapshot.scanResult?.cleanableCandidates.map(\.id) ?? [])
}
private func safetyText(_ safety: XcodeCleanSafetyStatus) -> String {
switch safety {
case .allowed:
return localization.string("detail.safety.allowed", defaultValue: "允许清理")
case .outsideAllowedRoot:
return localization.string("detail.safety.outsideAllowedRoot", defaultValue: "路径越界保护")
case .xcodeRunning:
return localization.string("detail.safety.xcodeRunning", defaultValue: "Xcode 运行中")
case .missing:
return localization.string("detail.safety.missing", defaultValue: "路径已不存在")
}
}
private func itemCountText(_ count: Int) -> String {
localization.format("detail.itemCount", defaultValue: "%d 项", count)
}
private func byteText(_ bytes: Int64) -> String {
XcodeCleanByteFormatter.string(fromByteCount: bytes)
}
private func logIconName(_ tone: XcodeCleanScanLogTone) -> String {
switch tone {
case .info: return "circle"
case .success: return "checkmark.circle.fill"
case .warning: return "shield.fill"
case .error: return "xmark.octagon.fill"
}
}
private func logColor(_ tone: XcodeCleanScanLogTone) -> Color {
switch tone {
case .info: return .secondary
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}
}