// Copyright (C) 2026 ysicing // SPDX-License-Identifier: AGPL-3.0-or-later import AppKit import Cleaner import Localization import Metrics import SwiftUI struct ProjectPurgeWindowView: View { @Environment(AppModel.self) private var model @State private var showingPaths = false var body: some View { VStack(spacing: 0) { HStack { Text(tr("项目产物")) .dsFont(.base, weight: .semibold) .foregroundStyle(DS.Palette.textPrimary) Spacer() Button { showingPaths = true } label: { Label(tr("扫描位置"), systemImage: "folder") } .buttonStyle(DSButtonStyle(kind: .secondary)) .disabled(model.projectPurge.isBusy) } .padding(.leading, DS.Size.trafficLightsWidth + DS.Space.s4) .padding(.trailing, DS.Space.s3) .frame(height: DS.Size.windowHeader) .background(WindowDragArea()) .background(DS.Palette.background) .zIndex(1) if model.projectPurge.phase == .idle && model.projectPurge.result == nil { ProjectPurgeEmptyState() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { PageScroll { ProjectPurgeCard() } } } .background(DS.Palette.background) .ignoresSafeArea() .appLanguageEnvironment() .sheet(isPresented: $showingPaths) { ProjectPurgePathsSheet() } } } private struct ProjectPurgePathsSheet: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss @State private var addFailed = false var body: some View { let purge = model.projectPurge VStack(alignment: .leading, spacing: DS.Space.s3) { Text(tr("扫描目录")) .dsFont(.base, weight: .semibold) Text(tr("仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。")) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) if !purge.pathsLoaded { ProgressView().controlSize(.small) .frame(maxWidth: .infinity) .padding(.vertical, DS.Space.s4) } else if purge.roots.isEmpty { Text(tr("没有扫描目录")) .dsFont(.sm).foregroundStyle(DS.Palette.textTertiary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, DS.Space.s4) } else { ScrollView { VStack(spacing: DS.Space.s1) { ForEach(purge.roots, id: \.path) { root in HStack(spacing: DS.Space.s2) { Image(systemName: "folder").foregroundStyle(DS.Palette.textSecondary) Text(root.path).dsFont(.sm) .lineLimit(1).truncationMode(.middle).help(root.path) Spacer(minLength: DS.Space.s2) Button { purge.removeRoot(root) } label: { Image(systemName: "minus.circle") } .buttonStyle(.plain) .help(tr("移除目录")) .accessibilityLabel(tr("移除目录")) .disabled(purge.isBusy || purge.isLoadingPaths) } .padding(.vertical, DS.Space.s1) } } } .frame(maxHeight: 260) } if addFailed { Text(tr("目录无效或已经添加")) .dsFont(.xs).foregroundStyle(DS.Palette.warning) } HStack { Button(tr("添加目录…")) { chooseDirectories() } .buttonStyle(DSButtonStyle(kind: .secondary)) .disabled(purge.isBusy || !purge.pathsLoaded || purge.isLoadingPaths) Button(tr("恢复默认")) { addFailed = false Task { await purge.restoreDefaults() } } .buttonStyle(DSButtonStyle(kind: .secondary)) .disabled(purge.isBusy || purge.isLoadingPaths || purge.isUsingDefaults) Spacer() Button(tr("完成")) { dismiss() } .buttonStyle(DSButtonStyle(kind: .primary)) } } .padding(DS.Space.s4) .frame(width: 580) .onAppear { Task { await model.projectPurge.loadPaths() } } } private func chooseDirectories() { let panel = NSOpenPanel() panel.title = tr("选择项目目录") panel.prompt = tr("添加") panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = true panel.canCreateDirectories = false guard panel.runModal() == .OK else { return } addFailed = false for url in panel.urls { if !model.projectPurge.addRoot(url) { addFailed = true } } } } private struct ProjectPurgeEmptyState: View { @Environment(AppModel.self) private var model var body: some View { VStack(spacing: DS.Space.s3) { Image(systemName: "shippingbox") .font(.system(size: 40, weight: .light)) .foregroundStyle(DS.Palette.textTertiary) Text(tr("尚未扫描项目")) .dsFont(.base, weight: .semibold) .foregroundStyle(DS.Palette.textPrimary) Text(tr("按需扫描项目构建产物,逐项选择后移到废纸篓。")) .dsFont(.sm) .foregroundStyle(DS.Palette.textSecondary) .multilineTextAlignment(.center) Button(tr("扫描项目")) { model.projectPurge.scan() } .buttonStyle(DSButtonStyle(kind: .primary)) } .frame(maxWidth: 440) .padding(DS.Space.s4) } } // MARK: - 项目产物 private struct ProjectPurgeCard: View { @Environment(AppModel.self) private var model var body: some View { let purge = model.projectPurge Card(padding: DS.Space.s3, spacing: DS.Space.s2) { HStack(spacing: DS.Space.s2) { Text(tr("按需扫描项目构建产物,逐项选择后移到废纸篓。")) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) Spacer(minLength: DS.Space.s2) if let result = purge.result { Text(verbatim: tr("\(result.items.count) 项 · \(Format.bytes(result.items.reduce(0) { $0 + $1.bytes }, base: .decimal))")) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) } } HStack(spacing: DS.Space.s2) { if purge.phase == .scanning { ProgressView().controlSize(.small) Text(tr("正在扫描项目…")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) Button(tr("取消")) { purge.cancelScan() }.buttonStyle(DSButtonStyle(kind: .secondary)) } else if purge.phase == .cleaning { ProgressView().controlSize(.small) Text(tr("正在清理…")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) Button(tr("取消")) { purge.cancelClean() }.buttonStyle(DSButtonStyle(kind: .secondary)) } else { Button(purge.result == nil ? tr("扫描项目") : tr("重新扫描")) { purge.scan() } .buttonStyle(DSButtonStyle(kind: .secondary)) if purge.result != nil { Button(tr("移到废纸篓 \(Format.bytes(purge.selectedBytes, base: .decimal))")) { purge.requestClean() } .buttonStyle(DSButtonStyle(kind: .primary)) .disabled(purge.selectedItems.isEmpty) } } Spacer() } if purge.isConfirming { let selected = purge.selectedItems let warnings = [selected.contains(where: \.isRecent) ? tr("所选内容包含最近 7 天有活动的项目,清理后需要重新构建。") : nil, selected.contains(where: \.isCloud) ? tr("云盘项目可能同步删除。") : nil].compactMap { $0 } let text = ([tr("将 \(selected.count) 项移到废纸篓,清空前可以恢复。")] + warnings).joined(separator: " ") InfoBanner(icon: "exclamationmark.triangle.fill", text: text, tone: .warning) { Button(tr("取消")) { purge.isConfirming = false }.buttonStyle(DSButtonStyle(kind: .secondary)) Button(tr("确认清理")) { purge.confirmClean() }.buttonStyle(DSButtonStyle(kind: .primary)) } } if let report = purge.report, purge.phase == .finished { Text(tr("已移到废纸篓 \(report.trashedCount) 项 · \(Format.bytes(report.trashedBytes, base: .decimal))")) .dsFont(.xs).foregroundStyle(DS.Palette.success) if report.skippedCount > 0 || !report.failures.isEmpty { Text(tr("跳过 \(report.skippedCount) 项 · 失败 \(report.failures.count) 项")) .dsFont(.xs).foregroundStyle(DS.Palette.warning) } } if let result = purge.result { if !result.failedRoots.isEmpty { Text(tr("\(result.failedRoots.count) 个目录未完成扫描,结果已排除")) .dsFont(.xs).foregroundStyle(DS.Palette.warning) ForEach(result.failedRoots, id: \.path) { root in Text(root.path).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) .lineLimit(1).truncationMode(.middle).help(root.path) } } if result.protectedCount > 0 { Text(tr("\(result.protectedCount) 项含手写或被 Git 跟踪的内容,已保护")) .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } if result.unverifiedCount > 0 { Text(result.developerToolsMissing ? tr("未安装 Xcode 命令行工具,无法确认 Git 仓库中的 \(result.unverifiedCount) 项是否被跟踪,已保留") : tr("\(result.unverifiedCount) 项无法确认,已保留")) .dsFont(.xs).foregroundStyle(DS.Palette.warning) .fixedSize(horizontal: false, vertical: true) } if result.items.isEmpty && result.failedRoots.isEmpty { Text(tr("没有发现可清理的项目产物")) .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } LazyVStack(alignment: .leading, spacing: DS.Space.s2) { ForEach(groups) { group in VStack(alignment: .leading, spacing: DS.Space.s1) { HStack { Text(group.project.path).dsFont(.xs, weight: .semibold) .lineLimit(1).truncationMode(.middle).help(group.project.path) Spacer() Text(verbatim: Format.bytes(group.bytes, base: .decimal)) .dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) } ForEach(group.items) { item in ProjectPurgeRow(item: item) } } .padding(.top, DS.Space.s1) } } } } } private var groups: [ProjectPurgeGroup] { let items = model.projectPurge.result?.items ?? [] // 扫描结果已按项目总大小排好,这里保持首次出现的顺序 let grouped = Dictionary(grouping: items, by: \.project.path) var seen = Set() return items.compactMap { item in guard seen.insert(item.project.path).inserted, let members = grouped[item.project.path] else { return nil } return ProjectPurgeGroup(project: item.project, items: members) } } } private struct ProjectPurgeGroup: Identifiable { var id: String { project.path } let project: URL let items: [ProjectPurgeItem] var bytes: UInt64 { items.reduce(0) { $0 + $1.bytes } } } private struct ProjectPurgeRow: View { @Environment(AppModel.self) private var model let item: ProjectPurgeItem var body: some View { let purge = model.projectPurge HStack(spacing: DS.Space.s2) { DSCheckbox(isOn: purge.selection.contains(item.id)) { purge.toggle(item) } .disabled(purge.isBusy) Image(systemName: "folder").foregroundStyle(DS.Palette.textSecondary) VStack(alignment: .leading, spacing: 0) { Text(item.url.lastPathComponent).dsFont(.sm, weight: .medium) HStack(spacing: DS.Space.s1) { Text(item.isRecent ? tr("最近 7 天有活动") : tr("超过 7 天未使用")) if item.isCloud { Text(tr("云盘")) } if item.isWorktree { Text(tr("Agent 工作树")) } } .dsFont(.xs).foregroundStyle(item.isRecent || item.isCloud ? DS.Palette.warning : DS.Palette.textTertiary) } Spacer() Text(verbatim: Format.bytes(item.bytes, base: .decimal)).dsFont(.sm).monospacedDigit() Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: { Image(systemName: "arrow.up.forward.square") } .buttonStyle(.plain).help(tr("在访达中显示")) } } }