feat(cleaner): 新增项目产物清理与扫描目录管理

This commit is contained in:
ysicing committed 2026-09-28 02:55:16 +08:00
1 parent 7e20b042f9
commit 147f94ca70
25 files changed
+1902 -8

No files matched your search

+2
View File
@@ -63,6 +63,8 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
`XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。
若钥匙串里有多张同名证书,应确保所选证书包含在两个 profile 中;可用 `SIGN_ID` 指定对应证书的 SHA-1 指纹。
清理页顶栏的“项目产物”入口打开独立窗口;项目扫描独立于普通缓存规则,只在用户点击后运行。默认扫描目录来自 Mole 的默认项目位置、Agent 工作树及自动发现的项目目录;“扫描位置”显示当前完整列表,可新增、移除默认或自定义目录,并恢复默认。第一次修改后保存完整列表,被移除的默认目录不会在下次扫描时自动加入;恢复默认会重新发现默认目录。目录配置只保存在本机偏好中,不加入跨设备备份。每个根目录最多向下查找 6 层;最近 7 天有活动的条目会标记提醒。应用包及其内部目录不会被当作项目扫描。Git 跟踪内容、嵌套仓库、部署密钥、符号链接及未完成安全检查的条目不会作为可清理候选;用户逐项选择并确认后,每项移到废纸篓前重新检查 Git 跟踪状态与路径身份。云盘候选会提示同步删除风险。实现不依赖 Mole 命令,也不会在后台常驻扫描。
菜单栏应用没有普通窗口,可以用已安装的应用渲染实时截图:
```bash
@@ -167,12 +167,16 @@ public struct CleanLog: Sendable {
}
func record(item: CleanItem, rule: CleanRule, action: String, detail: String?) {
record(url: item.url, ruleID: rule.id, bytes: item.size, action: action, detail: detail)
}
func record(url: URL, ruleID: String, bytes: UInt64, action: String, detail: String?) {
var entry: [String: Any] = [
"time": ISO8601DateFormatter().string(from: Date()),
"rule": rule.id,
"rule": ruleID,
"action": action,
"path": item.url.path,
"bytes": item.size,
"path": url.path,
"bytes": bytes,
]
if let detail { entry["detail"] = detail }
guard var data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { return }
@@ -0,0 +1,580 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Darwin
import Foundation
import Localization
public struct ProjectPurgeItem: Identifiable, Sendable {
public var id: String { url.path }
public let url: URL
public let project: URL
public let root: URL
public let bytes: UInt64
public let isRecent: Bool
public let isCloud: Bool
public let isWorktree: Bool
fileprivate let identity: Identity
fileprivate let parentIdentity: Identity
fileprivate let rootIdentity: Identity
fileprivate let latestModification: Date
}
public struct ProjectPurgeScan: Sendable {
public let items: [ProjectPurgeItem]
public let failedRoots: [URL]
/// 含 `.git`、部署密钥或被 Git 跟踪的文件,确认不能清理
public let protectedCount: Int
/// 检查超时、读取失败或无法确认 Git 状态,按不确定保留
public let unverifiedCount: Int
/// 未安装 Xcode 命令行工具:Git 仓库内的产物无法确认是否被跟踪,全部保留
public let developerToolsMissing: Bool
init(items: [ProjectPurgeItem], failedRoots: [URL], protectedCount: Int = 0,
unverifiedCount: Int = 0, developerToolsMissing: Bool = false) {
self.items = items
self.failedRoots = failedRoots
self.protectedCount = protectedCount
self.unverifiedCount = unverifiedCount
self.developerToolsMissing = developerToolsMissing
}
/// 清理后只去掉已移到废纸篓的项,不必整体重扫
public func removing(_ ids: Set<String>) -> ProjectPurgeScan {
ProjectPurgeScan(items: items.filter { !ids.contains($0.id) }, failedRoots: failedRoots,
protectedCount: protectedCount, unverifiedCount: unverifiedCount,
developerToolsMissing: developerToolsMissing)
}
}
public struct ProjectPurgeReport: Sendable {
public var trashedCount = 0
public var trashedBytes: UInt64 = 0
public var trashedIDs: Set<String> = []
public var skippedCount = 0
public var failures: [String] = []
}
private struct Identity: Hashable, Sendable {
let device: UInt64
let inode: UInt64
}
/// 按需扫描项目内可重建产物;与通用缓存规则分开,避免一次勾选清理所有项目。
public enum ProjectPurge {
private static let targetNames: Set<String> = [
"node_modules", "target", "build", "dist", "venv", ".venv", ".pytest_cache", ".mypy_cache",
".tox", ".nox", ".ruff_cache", ".gradle", ".terragrunt-cache", "__pycache__", ".next",
".nuxt", ".output", "vendor", "bin", "obj", ".turbo", ".parcel-cache", ".dart_tool",
".zig-cache", "zig-out", ".angular", ".svelte-kit", ".astro", "coverage", "DerivedData",
"Pods", ".cxx", ".expo", ".build",
]
private static let projectMarkers = [
"package.json", "Cargo.toml", "go.mod", "pyproject.toml", "requirements.txt", "pom.xml",
"build.gradle", "terragrunt.hcl", "Gemfile", "composer.json", "pubspec.yaml", "Package.swift",
"Makefile", "build.zig", "build.zig.zon", ".git",
]
private static let excludedContainers: Set<String> = [
".git", "Library", ".Trash", "Applications", "Movies", "Music", "Pictures", "Public",
]
private static let cacheTag = "Signature: 8a477f597d28d172789f06886806bc55"
private static let age: TimeInterval = 7 * 86_400
/// Mole 的默认根目录及自动发现的项目位置;无法验证的已存在路径会在扫描结果中报出。
public static func defaultSearchRoots(home: String = NSHomeDirectory()) -> [URL] {
let homeURL = URL(fileURLWithPath: home, isDirectory: true)
let names = ["www", "dev", "Projects", "GitHub", "Code", "Workspace", "Repos", "Development",
"Library/CloudStorage", ".codex/worktrees", ".claude/worktrees"]
let manager = FileManager.default
var roots = names.map { homeURL.appendingPathComponent($0) }.filter { manager.fileExists(atPath: $0.path) }
if let children = try? manager.contentsOfDirectory(at: homeURL, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) {
for child in children where !child.lastPathComponent.hasPrefix(".") && !excludedContainers.contains(child.lastPathComponent) {
if hasProjects(inside: child, depth: 2) { roots.append(child) }
}
}
return uniqueRoots(roots)
}
/// 用户从目录选择器添加时规范化路径;扫描时仍会单独处理随后卸载或失联的目录。
public static func validatedAdditionalRoot(_ url: URL, home: String = NSHomeDirectory()) -> URL? {
let normalized = url.standardizedFileURL.resolvingSymlinksInPath()
guard isAllowedAdditionalPath(normalized, home: URL(fileURLWithPath: home)),
identity(of: normalized) != nil else { return nil }
return normalized
}
/// APFS 路径大小写或别名不同,也不能把同一个目录添加两次。
public static func sameRoot(_ first: URL, _ second: URL) -> Bool {
if let firstID = identity(of: first), let secondID = identity(of: second) {
return firstID == secondID
}
return first.standardizedFileURL.resolvingSymlinksInPath().path
== second.standardizedFileURL.resolvingSymlinksInPath().path
}
public static func scan(home: String = NSHomeDirectory(), now: Date = Date(),
configuredRoots: [URL]? = nil) async -> ProjectPurgeScan {
await scan(home: home, now: now, configuredRoots: configuredRoots, developerTools: nil)
}
/// `developerTools` 为 nil 时检测本机;测试可指定有无 Xcode 命令行工具
static func scan(home: String, now: Date, configuredRoots: [URL]?, developerTools: Bool?) async -> ProjectPurgeScan {
await withTaskGroup(of: ProjectPurgeScan.self) { group in
group.addTask(priority: .utility) {
await scanRoots(home: home, now: now, configuredRoots: configuredRoots, developerTools: developerTools)
}
return await group.next() ?? ProjectPurgeScan(items: [], failedRoots: [])
}
}
public static func clean(_ scan: ProjectPurgeScan, selected: Set<String>) async -> ProjectPurgeReport {
await clean(scan, selected: selected, trash: { url in
try FileManager.default.trashItem(at: url, resultingItemURL: nil)
}, log: CleanLog())
}
static func clean(_ scan: ProjectPurgeScan, selected: Set<String>,
trash: @escaping @Sendable (URL) throws -> Void,
log: CleanLog? = nil, developerTools: Bool? = nil) async -> ProjectPurgeReport {
await withTaskGroup(of: ProjectPurgeReport.self) { group in
group.addTask(priority: .utility) {
var report = ProjectPurgeReport()
let chosen = scan.items.filter { selected.contains($0.id) }
let gitAvailable = developerTools ?? developerToolsInstalled()
for item in chosen {
if Task.isCancelled { break }
guard identitiesMatch(item), isContained(item.url, in: item.root),
isProject(item.project), isTarget(item.url, parent: item.url.deletingLastPathComponent()),
case .contents(let bytes, let latest) = inspect(item.url, deadline: Date().addingTimeInterval(10)),
latest <= item.latestModification else {
report.skippedCount += 1
log?.record(url: item.url, ruleID: "projects.purge", bytes: item.bytes,
action: "skip", detail: "safety check")
continue
}
// 批量清理期间可能发生 git add;内容检查完成后逐项复核,不能复用批次开始时的状态。
let tracking = trackingStates(for: [(item.url, item.project)], gitAvailable: gitAvailable)
guard !Task.isCancelled, tracking[item.url.path] == .untracked,
identitiesMatch(item), isContained(item.url, in: item.root),
isTarget(item.url, parent: item.url.deletingLastPathComponent()) else {
report.skippedCount += 1
log?.record(url: item.url, ruleID: "projects.purge", bytes: item.bytes,
action: "skip", detail: "path or tracking changed")
continue
}
do {
try trash(item.url)
report.trashedCount += 1
report.trashedBytes += bytes
report.trashedIDs.insert(item.id)
log?.record(url: item.url, ruleID: "projects.purge", bytes: bytes,
action: "trash", detail: nil)
} catch {
report.failures.append(tr("\(item.url.lastPathComponent):\(error.localizedDescription)"))
log?.record(url: item.url, ruleID: "projects.purge", bytes: item.bytes,
action: "fail", detail: error.localizedDescription)
}
}
return report
}
return await group.next() ?? ProjectPurgeReport()
}
}
/// 同时最多扫描 3 个目录:一个大目录不会耗尽其余目录的时间,也不至于让磁盘读写互相争抢。
private static func scanRoots(home: String, now: Date, configuredRoots: [URL]?, developerTools: Bool?) async -> ProjectPurgeScan {
let roots: [URL]
if let configuredRoots {
let homeURL = URL(fileURLWithPath: home, isDirectory: true)
roots = uniqueRoots(configuredRoots.filter { isAllowedAdditionalPath($0, home: homeURL) })
} else {
roots = defaultSearchRoots(home: home)
}
let gitAvailable = developerTools ?? developerToolsInstalled()
let overallDeadline = Date().addingTimeInterval(120)
var results = [RootScan?](repeating: nil, count: roots.count)
await withTaskGroup(of: (Int, RootScan?).self) { group in
var next = 0
func startNext() {
guard next < roots.count else { return }
let index = next
next += 1
group.addTask(priority: .utility) {
let deadline = min(overallDeadline, Date().addingTimeInterval(45))
guard !Task.isCancelled, Date() < deadline else { return (index, nil) }
return (index, scanRoot(roots[index], home: home, now: now, deadline: deadline, gitAvailable: gitAvailable))
}
}
for _ in 0..<3 { startNext() }
for await (index, result) in group {
results[index] = result
startNext()
}
}
var items: [ProjectPurgeItem] = []
var failedRoots: [URL] = []
var protected = 0, unverified = 0
var toolsMissing = false
for (root, result) in zip(roots, results) {
guard let result else { failedRoots.append(root); continue }
items += result.items
protected += result.protected
unverified += result.unverified
toolsMissing = toolsMissing || result.developerToolsMissing
}
var seenItems = Set<String>()
items = items.filter { seenItems.insert($0.url.resolvingSymlinksInPath().path).inserted }
// 占用最大的项目排在前面,组内按单项大小排序
var projectBytes: [String: UInt64] = [:]
for item in items { projectBytes[item.project.path, default: 0] &+= item.bytes }
items.sort { lhs, rhs in
let left = projectBytes[lhs.project.path] ?? 0, right = projectBytes[rhs.project.path] ?? 0
if lhs.project.path != rhs.project.path {
return left == right ? lhs.project.path < rhs.project.path : left > right
}
return lhs.bytes > rhs.bytes
}
return ProjectPurgeScan(items: items, failedRoots: failedRoots, protectedCount: protected,
unverifiedCount: unverified, developerToolsMissing: toolsMissing)
}
private static func uniqueRoots(_ roots: [URL]) -> [URL] {
var identities = Set<Identity>()
var unresolvedPaths = Set<String>()
return roots.filter { root in
// 无法验证的根仍交给扫描阶段报告;同一个缺失路径只报告一次。
guard let id = identity(of: root) else {
return unresolvedPaths.insert(root.standardizedFileURL.path).inserted
}
return identities.insert(id).inserted
}
}
private static func isAllowedAdditionalPath(_ url: URL, home: URL) -> Bool {
guard url.isFileURL, url.path.hasPrefix("/"), !url.pathComponents.contains(".."),
!url.path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { return false }
// 下载目录内的应用也有 package.json 和运行依赖,应用包及其内部路径不能作为项目根。
guard !url.standardizedFileURL.resolvingSymlinksInPath().pathComponents.contains(where: {
$0.lowercased().hasSuffix(".app")
}) else { return false }
let path = url.standardizedFileURL.path
let homePath = home.standardizedFileURL.resolvingSymlinksInPath().path
guard !["/", "/Users", "/Volumes", "/private", "/var", homePath, homePath + "/Library"].contains(path) else {
return false
}
for systemRoot in ["/System", "/Applications", "/Library", "/usr", "/bin", "/sbin", "/dev"] {
if path == systemRoot || path.hasPrefix(systemRoot + "/") { return false }
}
guard !url.pathComponents.contains(where: { targetNames.contains($0) || $0 == ".Trash" || $0 == ".git" }) else {
return false
}
// 应用数据与编辑器扩展里也有 package.json,但不是用户项目,清掉其中的 dist/ 会损坏它们。
// 家目录的资源库只允许云盘;隐藏目录只允许 Agent 工作树。
var relative: [String]?
for prefix in Set([homePath, home.standardizedFileURL.path]) where path.hasPrefix(prefix + "/") {
relative = String(path.dropFirst(prefix.count + 1)).split(separator: "/").map(String.init)
}
guard let relative else { return !url.standardizedFileURL.pathComponents.contains { $0.hasPrefix(".") } }
let rest = relative.dropFirst(2)
if relative.first == "Library" {
return relative.count >= 2 && ["CloudStorage", "Mobile Documents"].contains(relative[1])
&& !rest.contains { $0.hasPrefix(".") }
}
if let first = relative.first, first.hasPrefix(".") {
return [".codex", ".claude"].contains(first) && relative.count >= 2 && relative[1] == "worktrees"
&& !rest.contains { $0.hasPrefix(".") }
}
return !relative.contains { $0.hasPrefix(".") }
}
private struct RootScan: Sendable {
var items: [ProjectPurgeItem] = []
var protected = 0
var unverified = 0
var developerToolsMissing = false
}
private struct Candidate {
let url: URL
let project: URL
let identity: Identity
let parentIdentity: Identity
let rootIdentity: Identity
let bytes: UInt64
let latest: Date
}
private static func scanRoot(_ root: URL, home: String, now: Date, deadline: Date, gitAvailable: Bool) -> RootScan? {
guard identity(of: root) != nil else { return nil }
let manager = FileManager.default
var stack: [(url: URL, depth: Int, project: URL?)] = [(root, 0, isProject(root) ? root : nil)]
var candidates: [Candidate] = []
var result = RootScan()
while let frame = stack.popLast() {
if Task.isCancelled || Date() >= deadline { return nil }
guard let children = try? manager.contentsOfDirectory(at: frame.url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return nil }
for child in children {
if Task.isCancelled || Date() >= deadline { return nil }
guard isDirectoryWithoutLink(child), child.pathExtension.lowercased() != "app" else { continue }
let name = child.lastPathComponent
let eligibleArtifact = isTarget(child, parent: frame.url)
if eligibleArtifact, let project = frame.project {
guard isContained(child, in: root),
let targetIdentity = identity(of: child),
let parentIdentity = identity(of: frame.url),
let rootIdentity = identity(of: root) else {
result.unverified += 1
continue
}
switch inspect(child, deadline: min(deadline, Date().addingTimeInterval(10))) {
case .empty: break
case .authored: result.protected += 1
case .unreadable: result.unverified += 1
case .contents(let bytes, let latest):
candidates.append(Candidate(url: child, project: project, identity: targetIdentity,
parentIdentity: parentIdentity, rootIdentity: rootIdentity,
bytes: bytes, latest: latest))
}
continue
}
// 依赖目录即使没有可确认的项目所有者,也不能被当作项目容器继续向下搜索。
if eligibleArtifact || targetNames.contains(name) { continue }
if excludedContainers.contains(name) || name.hasPrefix(".") || frame.depth >= 5 { continue }
stack.append((child, frame.depth + 1, isProject(child) ? child : frame.project))
}
}
let tracking = trackingStates(for: candidates.map { ($0.url, $0.project) }, gitAvailable: gitAvailable)
let physicalHomePrefix = URL(fileURLWithPath: home).resolvingSymlinksInPath().path + "/"
let lexicalHomePrefix = URL(fileURLWithPath: home).path + "/"
for candidate in candidates {
switch tracking[candidate.url.path] ?? .unknown {
case .tracked: result.protected += 1; continue
case .unknown:
result.unverified += 1
if !gitAvailable { result.developerToolsMissing = true }
continue
case .untracked: break
}
let physicalPath = candidate.url.resolvingSymlinksInPath().path
let relative: String
if physicalPath.hasPrefix(physicalHomePrefix) {
relative = String(physicalPath.dropFirst(physicalHomePrefix.count))
} else if candidate.url.path.hasPrefix(lexicalHomePrefix) {
relative = String(candidate.url.path.dropFirst(lexicalHomePrefix.count))
} else {
relative = ""
}
result.items.append(ProjectPurgeItem(
url: candidate.url, project: candidate.project, root: root, bytes: candidate.bytes,
isRecent: candidate.latest > now.addingTimeInterval(-age),
isCloud: relative.hasPrefix("Library/CloudStorage/") || relative.hasPrefix("Library/Mobile Documents/"),
isWorktree: relative.hasPrefix(".codex/worktrees/") || relative.hasPrefix(".claude/worktrees/"),
identity: candidate.identity, parentIdentity: candidate.parentIdentity,
rootIdentity: candidate.rootIdentity, latestModification: candidate.latest))
}
return result
}
private static func isProject(_ directory: URL) -> Bool {
projectMarkers.contains { FileManager.default.fileExists(atPath: directory.appendingPathComponent($0).path) }
}
private static func hasProjects(inside directory: URL, depth: Int) -> Bool {
guard isDirectoryWithoutLink(directory), directory.pathExtension.lowercased() != "app",
!targetNames.contains(directory.lastPathComponent) else { return false }
if isProject(directory) { return true }
guard depth > 0, let children = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return false }
return children.contains { child in
!child.lastPathComponent.hasPrefix(".") && !excludedContainers.contains(child.lastPathComponent)
&& !targetNames.contains(child.lastPathComponent)
&& hasProjects(inside: child, depth: depth - 1)
}
}
private static func isTarget(_ url: URL, parent: URL) -> Bool {
let name = url.lastPathComponent
if name == "vendor" { return FileManager.default.fileExists(atPath: parent.appendingPathComponent("composer.json").path) }
if name == "bin" {
let hasProject = ((try? FileManager.default.contentsOfDirectory(atPath: parent.path)) ?? [])
.contains { $0.hasSuffix(".csproj") || $0.hasSuffix(".fsproj") || $0.hasSuffix(".vbproj") }
return hasProject && (FileManager.default.fileExists(atPath: url.appendingPathComponent("Debug").path)
|| FileManager.default.fileExists(atPath: url.appendingPathComponent("Release").path))
}
if targetNames.contains(name) { return true }
let tag = url.appendingPathComponent("CACHEDIR.TAG")
guard FileManager.default.fileExists(atPath: tag.path),
let values = try? tag.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
values.isRegularFile == true, values.isSymbolicLink != true else { return false }
guard let handle = try? FileHandle(forReadingFrom: tag) else { return false }
defer { try? handle.close() }
let prefix = try? handle.read(upToCount: cacheTag.utf8.count)
return prefix == Data(cacheTag.utf8)
}
private enum Inspection {
case contents(bytes: UInt64, latest: Date)
/// 没有占用空间,不值得列出
case empty
/// 含 `.git` 或部署密钥等手写内容
case authored
/// 超时或读取失败,无法确认
case unreadable
}
private static func inspect(_ url: URL, deadline: Date) -> Inspection {
let keys: Set<URLResourceKey> = [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey,
.contentModificationDateKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
guard let values = try? url.resourceValues(forKeys: keys), values.isDirectory == true,
values.isSymbolicLink != true,
let directoryModified = values.contentModificationDate else { return .unreadable }
var latest = directoryModified
var bytes: UInt64 = 0
var failed = false
guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: Array(keys), options: [],
errorHandler: { _, _ in failed = true; return false }) else { return .unreadable }
while let entry = enumerator.nextObject() as? URL {
if Task.isCancelled || Date() >= deadline { return .unreadable }
let name = entry.lastPathComponent
if name == ".git" || name.hasSuffix("-keypair.json") { return .authored }
guard let file = try? entry.resourceValues(forKeys: keys) else { return .unreadable }
if file.isSymbolicLink == true { enumerator.skipDescendants(); continue }
guard let modified = file.contentModificationDate else { return .unreadable }
if modified > latest { latest = modified }
if file.isRegularFile == true {
let size = UInt64(max(0, file.totalFileAllocatedSize ?? file.fileAllocatedSize ?? 0))
let (sum, overflow) = bytes.addingReportingOverflow(size)
bytes = overflow ? UInt64.max : sum
}
}
if failed { return .unreadable }
return bytes > 0 ? .contents(bytes: bytes, latest: latest) : .empty
}
private enum Tracking { case untracked, tracked, unknown }
/// 按仓库分组,每个仓库只运行一次 `git ls-files`(每批最多 200 个路径)。键为产物的路径。
private static func trackingStates(for targets: [(url: URL, project: URL)], gitAvailable: Bool) -> [String: Tracking] {
var states: [String: Tracking] = [:]
var byRepository: [String: [(url: URL, relative: String)]] = [:]
for target in targets {
var repository = target.project
while repository.path != "/" && !FileManager.default.fileExists(atPath: repository.appendingPathComponent(".git").path) {
repository.deleteLastPathComponent()
}
guard repository.path != "/" else { states[target.url.path] = .untracked; continue }
let prefix = repository.path + "/"
guard gitAvailable, target.url.path.hasPrefix(prefix) else { states[target.url.path] = .unknown; continue }
byRepository[repository.path, default: []].append((target.url, String(target.url.path.dropFirst(prefix.count))))
}
for (repository, entries) in byRepository {
for start in stride(from: 0, to: entries.count, by: 200) {
if Task.isCancelled { return states }
let batch = Array(entries[start..<min(start + 200, entries.count)])
guard let tracked = trackedFiles(in: repository, paths: batch.map(\.relative)) else {
for entry in batch { states[entry.url.path] = .unknown }
continue
}
let relatives = Set(batch.map(\.relative))
var trackedTargets = Set<String>()
for file in tracked {
// 找出这个被跟踪文件属于哪个产物目录:逐级比对它的上级路径
var components = file.split(separator: "/", omittingEmptySubsequences: false)
while !components.isEmpty {
let candidate = components.joined(separator: "/")
if relatives.contains(candidate) { trackedTargets.insert(candidate); break }
components.removeLast()
}
}
for entry in batch { states[entry.url.path] = trackedTargets.contains(entry.relative) ? .tracked : .untracked }
}
}
return states
}
/// 返回这些路径下被 Git 跟踪的文件;失败或超时返回 nil
private static func trackedFiles(in repository: String, paths: [String]) -> [String]? {
let output = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-git-\(UUID())")
guard FileManager.default.createFile(atPath: output.path, contents: nil),
let handle = try? FileHandle(forWritingTo: output) else { return nil }
defer {
try? handle.close()
try? FileManager.default.removeItem(at: output)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-c", "core.fsmonitor=false", "-C", repository, "ls-files", "--cached", "-z", "--"] + paths
process.standardOutput = handle
process.standardError = FileHandle.nullDevice
var environment = ProcessInfo.processInfo.environment
for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"] { environment.removeValue(forKey: key) }
environment["GIT_OPTIONAL_LOCKS"] = "0"
environment["GIT_LITERAL_PATHSPECS"] = "1"
process.environment = environment
guard wait(for: process, timeout: 10) == 0,
let data = try? Data(contentsOf: output) else { return nil }
return data.split(separator: 0).map { String(decoding: $0, as: UTF8.self) }
}
/// 运行子进程并限时等待;超时先 terminate 再 SIGKILL。返回退出码,启动失败或超时返回 nil
private static func wait(for process: Process, timeout: TimeInterval) -> Int32? {
do { try process.run() } catch { return nil }
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning && Date() < deadline && !Task.isCancelled {
Thread.sleep(forTimeInterval: 0.02)
}
if process.isRunning {
process.terminate()
let grace = Date().addingTimeInterval(1)
while process.isRunning && Date() < grace { Thread.sleep(forTimeInterval: 0.02) }
if process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) }
process.waitUntilExit()
return nil
}
return process.terminationStatus
}
/// 没有 Xcode 或命令行工具时,/usr/bin/git 只是占位程序,调用会弹出安装对话框。
/// `xcode-select -p` 不会弹窗:未安装时直接以非零状态退出。
static func developerToolsInstalled(xcodeSelect: String = "/usr/bin/xcode-select") -> Bool {
let output = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-xcode-select-\(UUID())")
guard FileManager.default.createFile(atPath: output.path, contents: nil),
let handle = try? FileHandle(forWritingTo: output) else { return false }
defer {
try? handle.close()
try? FileManager.default.removeItem(at: output)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: xcodeSelect)
process.arguments = ["-p"]
process.standardOutput = handle
process.standardError = FileHandle.nullDevice
guard wait(for: process, timeout: 3) == 0, let data = try? Data(contentsOf: output) else { return false }
let path = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
return !path.isEmpty && FileManager.default.isExecutableFile(atPath: path + "/usr/bin/git")
}
private static func identity(of url: URL) -> Identity? {
guard isDirectoryWithoutLink(url),
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let device = attributes[.systemNumber] as? NSNumber,
let inode = attributes[.systemFileNumber] as? NSNumber else { return nil }
return Identity(device: device.uint64Value, inode: inode.uint64Value)
}
private static func isDirectoryWithoutLink(_ url: URL) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return false }
return values.isDirectory == true && values.isSymbolicLink != true
}
private static func identitiesMatch(_ item: ProjectPurgeItem) -> Bool {
identity(of: item.root) == item.rootIdentity
&& identity(of: item.url.deletingLastPathComponent()) == item.parentIdentity
&& identity(of: item.url) == item.identity
}
private static func isContained(_ item: URL, in root: URL) -> Bool {
let physicalRoot = root.resolvingSymlinksInPath().path
let physicalItem = item.resolvingSymlinksInPath().path
return physicalItem.hasPrefix(physicalRoot + "/")
}
}
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} ملاحظات XStats · {}
应用版本:{} إصدار التطبيق: {}
系统版本:{} إصدار النظام: {}
请描述遇到的问题: صف المشكلة:
项目产物 مخرجات المشاريع
按需扫描项目构建产物,逐项选择后移到废纸篓。 افحص مخرجات بناء المشاريع عند الطلب، ثم انقل العناصر المحددة إلى سلة المهملات.
正在扫描项目… جارٍ فحص المشاريع…
扫描项目 فحص المشاريع
移到废纸篓 {} نقل {} إلى سلة المهملات
将 {} 项移到废纸篓,清空前可以恢复。 نقل {} عنصرًا إلى سلة المهملات؛ يمكن استعادتها قبل إفراغها.
已移到废纸篓 {} 项 · {} نُقل {} عنصرًا إلى سلة المهملات · {}
跳过 {} 项 · 失败 {} 项 تم تخطي {} · فشل {}
{} 个目录未完成扫描,结果已排除 تعذر إكمال فحص {} دليلًا؛ استُبعدت نتائجها
没有发现可清理的项目产物 لم يُعثر على مخرجات مشاريع قابلة للتنظيف
最近 7 天有活动 نشاط خلال آخر 7 أيام
超过 7 天未使用 غير مستخدم منذ أكثر من 7 أيام
云盘 تخزين سحابي
Agent 工作树 شجرة عمل Agent
{} 项 · {} {} عناصر · {}
尚未扫描项目 لم تُفحص المشاريع بعد
扫描位置 مواقع الفحص
扫描目录 مجلدات الفحص
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 سيتم فحص المجلدات في هذه القائمة فقط. يعيد «استعادة الافتراضي» المواقع الافتراضية التي أُزيلت.
没有扫描目录 لا توجد مجلدات للفحص
移除目录 إزالة المجلد
目录无效或已经添加 المجلد غير صالح أو تمت إضافته مسبقًا
添加目录… إضافة مجلد…
恢复默认 استعادة الافتراضي
完成 تم
选择项目目录 اختيار مجلدات المشاريع
添加 إضافة
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 يتضمن التحديد مشاريع نشطة خلال آخر 7 أيام؛ ستحتاج إلى إعادة بنائها.
云盘项目可能同步删除。 قد تُحذف مشاريع السحابة من أجهزتك الأخرى أيضًا.
{} 项含手写或被 Git 跟踪的内容,已保护 {} عنصر يحتوي على ملفات مكتوبة يدويًا أو يتتبعها Git، لذا تمت حمايته
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 أدوات سطر الأوامر في Xcode غير مثبّتة، لذا تعذّر فحص {} عنصر في مستودعات Git وتم الاحتفاظ به
{} 项无法确认,已保留 تعذّر التحقق من {} عنصر وتم الاحتفاظ به
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} XStats-Feedback · {}
应用版本:{} App-Version: {}
系统版本:{} Systemversion: {}
请描述遇到的问题: Beschreiben Sie das Problem:
项目产物 Projektartefakte
按需扫描项目构建产物,逐项选择后移到废纸篓。 Projekt-Buildartefakte bei Bedarf scannen und ausgewählte Elemente in den Papierkorb verschieben.
正在扫描项目… Projekte werden gescannt…
扫描项目 Projekte scannen
移到废纸篓 {} {} in den Papierkorb
将 {} 项移到废纸篓,清空前可以恢复。 {} Elemente in den Papierkorb verschieben; bis zum Leeren können sie wiederhergestellt werden.
已移到废纸篓 {} 项 · {} {} Elemente in den Papierkorb verschoben · {}
跳过 {} 项 · 失败 {} 项 Übersprungen: {} · Fehlgeschlagen: {}
{} 个目录未完成扫描,结果已排除 {} Verzeichnisse konnten nicht vollständig gescannt werden; ihre Ergebnisse wurden ausgeschlossen
没有发现可清理的项目产物 Keine bereinigbaren Projektartefakte gefunden
最近 7 天有活动 Aktivität in den letzten 7 Tagen
超过 7 天未使用 Seit über 7 Tagen ungenutzt
云盘 Cloudspeicher
Agent 工作树 Agent-Worktree
{} 项 · {} {} Elemente · {}
尚未扫描项目 Projekte noch nicht gescannt
扫描位置 Scanorte
扫描目录 Scanordner
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 Nur Ordner in dieser Liste werden gescannt. Mit „Standard wiederherstellen“ können Sie entfernte Standardorte zurückholen.
没有扫描目录 Keine Scanordner
移除目录 Ordner entfernen
目录无效或已经添加 Ordner ist ungültig oder bereits hinzugefügt
添加目录… Ordner hinzufügen…
恢复默认 Standard wiederherstellen
完成 Fertig
选择项目目录 Projektordner auswählen
添加 Hinzufügen
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 Die Auswahl enthält Projekte, die in den letzten 7 Tagen aktiv waren; sie müssen neu erstellt werden.
云盘项目可能同步删除。 Cloud-Projekte werden möglicherweise auch auf anderen Geräten gelöscht.
{} 项含手写或被 Git 跟踪的内容,已保护 {} Elemente enthalten selbst erstellte oder von Git verfolgte Dateien und sind geschützt
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Die Xcode-Befehlszeilentools sind nicht installiert. {} Elemente in Git-Repositorys konnten nicht geprüft werden und wurden behalten
{} 项无法确认,已保留 {} Elemente konnten nicht geprüft werden und wurden behalten
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} Comentarios sobre XStats · {}
应用版本:{} Versión de la app: {}
系统版本:{} Versión del sistema: {}
请描述遇到的问题: Describe el problema:
项目产物 Artefactos del proyecto
按需扫描项目构建产物,逐项选择后移到废纸篓。 Analiza los artefactos de compilación bajo demanda y mueve los elementos seleccionados a la Papelera.
正在扫描项目… Analizando proyectos…
扫描项目 Analizar proyectos
移到废纸篓 {} Mover {} a la Papelera
将 {} 项移到废纸篓,清空前可以恢复。 Mover {} elementos a la Papelera; pueden restaurarse hasta vaciarla.
已移到废纸篓 {} 项 · {} {} elementos movidos a la Papelera · {}
跳过 {} 项 · 失败 {} 项 Omitidos {} · Fallidos {}
{} 个目录未完成扫描,结果已排除 No se pudieron analizar por completo {} directorios; se excluyeron sus resultados
没有发现可清理的项目产物 No se encontraron artefactos del proyecto para limpiar
最近 7 天有活动 Actividad en los últimos 7 días
超过 7 天未使用 Sin usar durante más de 7 días
云盘 Almacenamiento en la nube
Agent 工作树 Árbol de trabajo del agente
{} 项 · {} {} elementos · {}
尚未扫描项目 Aún no se han analizado los proyectos
扫描位置 Ubicaciones de análisis
扫描目录 Carpetas de análisis
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 Solo se analizan las carpetas de esta lista. «Restaurar valores predeterminados» recupera las ubicaciones predeterminadas eliminadas.
没有扫描目录 No hay carpetas para analizar
移除目录 Quitar carpeta
目录无效或已经添加 La carpeta no es válida o ya se añadió
添加目录… Añadir carpeta…
恢复默认 Restaurar valores predeterminados
完成 Listo
选择项目目录 Elegir carpetas de proyectos
添加 Añadir
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 La selección incluye proyectos activos en los últimos 7 días; habrá que volver a compilarlos.
云盘项目可能同步删除。 Los proyectos en la nube también podrían eliminarse en otros dispositivos.
{} 项含手写或被 Git 跟踪的内容,已保护 {} elementos contienen archivos escritos a mano o seguidos por Git y están protegidos
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Las herramientas de línea de comandos de Xcode no están instaladas: no se pudieron comprobar {} elementos de repositorios Git y se conservaron
{} 项无法确认,已保留 No se pudieron comprobar {} elementos y se conservaron
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} Commentaires XStats · {}
应用版本:{} Version de l’app : {}
系统版本:{} Version du système : {}
请描述遇到的问题: Décrivez le problème :
项目产物 Artéfacts de projet
按需扫描项目构建产物,逐项选择后移到废纸篓。 Analysez les artéfacts de compilation à la demande, puis déplacez les éléments sélectionnés vers la corbeille.
正在扫描项目… Analyse des projets…
扫描项目 Analyser les projets
移到废纸篓 {} Déplacer {} vers la corbeille
将 {} 项移到废纸篓,清空前可以恢复。 Déplacer {} éléments vers la corbeille ; ils restent récupérables avant de la vider.
已移到废纸篓 {} 项 · {} {} éléments déplacés vers la corbeille · {}
跳过 {} 项 · 失败 {} 项 Ignorés : {} · Échecs : {}
{} 个目录未完成扫描,结果已排除 {} dossiers n’ont pas pu être analysés entièrement ; leurs résultats ont été exclus
没有发现可清理的项目产物 Aucun artéfact de projet à nettoyer
最近 7 天有活动 Activité au cours des 7 derniers jours
超过 7 天未使用 Inutilisé depuis plus de 7 jours
云盘 Stockage cloud
Agent 工作树 Arbre de travail Agent
{} 项 · {} {} éléments · {}
尚未扫描项目 Projets non encore analysés
扫描位置 Emplacements analysés
扫描目录 Dossiers analysés
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 Seuls les dossiers de cette liste sont analysés. « Rétablir les valeurs par défaut » restaure les emplacements par défaut supprimés.
没有扫描目录 Aucun dossier à analyser
移除目录 Retirer le dossier
目录无效或已经添加 Le dossier est invalide ou déjà ajouté
添加目录… Ajouter un dossier…
恢复默认 Rétablir les valeurs par défaut
完成 Terminé
选择项目目录 Choisir des dossiers de projets
添加 Ajouter
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 La sélection contient des projets actifs ces 7 derniers jours ; ils devront être recompilés.
云盘项目可能同步删除。 Les projets dans le cloud peuvent aussi être supprimés sur vos autres appareils.
{} 项含手写或被 Git 跟踪的内容,已保护 {} éléments contiennent des fichiers écrits à la main ou suivis par Git et sont protégés
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Les outils en ligne de commande Xcode ne sont pas installés : {} éléments dans des dépôts Git n’ont pas pu être vérifiés et ont été conservés
{} 项无法确认,已保留 {} éléments n’ont pas pu être vérifiés et ont été conservés
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} XStats フィードバック · {}
应用版本:{} アプリのバージョン:{}
系统版本:{} システムのバージョン:{}
请描述遇到的问题: 発生した問題を記入してください:
项目产物 プロジェクト生成物
按需扫描项目构建产物,逐项选择后移到废纸篓。 必要なときにプロジェクトの生成物をスキャンし、選択した項目をゴミ箱に移動します。
正在扫描项目… プロジェクトをスキャン中…
扫描项目 プロジェクトをスキャン
移到废纸篓 {} {} をゴミ箱に移動
将 {} 项移到废纸篓,清空前可以恢复。 {} 項目をゴミ箱に移動します。ゴミ箱を空にする前なら復元できます。
已移到废纸篓 {} 项 · {} {} 項目をゴミ箱に移動 · {}
跳过 {} 项 · 失败 {} 项 スキップ {} 件 · 失敗 {} 件
{} 个目录未完成扫描,结果已排除 {} 個のディレクトリを完全にスキャンできず、結果を除外しました
没有发现可清理的项目产物 削除可能なプロジェクト生成物はありません
最近 7 天有活动 過去7日間に使用
超过 7 天未使用 7日以上未使用
云盘 クラウドストレージ
Agent 工作树 Agentワークツリー
{} 项 · {} {} 項目 · {}
尚未扫描项目 プロジェクトはまだスキャンされていません
扫描位置 スキャン場所
扫描目录 スキャンフォルダ
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 この一覧のフォルダだけをスキャンします。削除した既定の場所は「既定に戻す」で復元できます。
没有扫描目录 スキャンフォルダがありません
移除目录 フォルダを削除
目录无效或已经添加 フォルダが無効か、すでに追加されています
添加目录… フォルダを追加…
恢复默认 既定に戻す
完成 完了
选择项目目录 プロジェクトフォルダを選択
添加 追加
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 選択に最近 7 日以内に使用したプロジェクトが含まれています。クリーンアップ後は再ビルドが必要です。
云盘项目可能同步删除。 クラウド上のプロジェクトは同期先でも削除される場合があります。
{} 项含手写或被 Git 跟踪的内容,已保护 {} 件は手書きのファイルまたは Git で管理されているファイルを含むため保護しました
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Xcode コマンドラインツールがインストールされていないため、Git リポジトリ内の {} 件を確認できず、保持しました
{} 项无法确认,已保留 {} 件は確認できなかったため保持しました
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1512,3 +1512,35 @@ XStats 反馈 · {} XStats 피드백 · {}
应用版本:{} 앱 버전: {}
系统版本:{} 시스템 버전: {}
请描述遇到的问题: 발생한 문제를 설명해 주세요:
项目产物 프로젝트 빌드 산출물
按需扫描项目构建产物,逐项选择后移到废纸篓。 필요할 때 프로젝트 빌드 산출물을 스캔하고 선택한 항목을 휴지통으로 이동합니다.
正在扫描项目… 프로젝트 스캔 중…
扫描项目 프로젝트 스캔
移到废纸篓 {} {} 휴지통으로 이동
将 {} 项移到废纸篓,清空前可以恢复。 {}개 항목을 휴지통으로 이동합니다. 휴지통을 비우기 전까지 복원할 수 있습니다.
已移到废纸篓 {} 项 · {} {}개 항목을 휴지통으로 이동 · {}
跳过 {} 项 · 失败 {} 项 건너뜀 {}개 · 실패 {}개
{} 个目录未完成扫描,结果已排除 {}개 디렉터리의 스캔이 완료되지 않아 결과에서 제외했습니다
没有发现可清理的项目产物 정리할 프로젝트 산출물이 없습니다
最近 7 天有活动 최근 7일 내 활동
超过 7 天未使用 7일 이상 미사용
云盘 클라우드 저장소
Agent 工作树 Agent 작업 트리
{} 项 · {} {}개 항목 · {}
尚未扫描项目 프로젝트를 아직 스캔하지 않았습니다
扫描位置 스캔 위치
扫描目录 스캔 폴더
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 이 목록의 폴더만 스캔합니다. 제거한 기본 위치는 ‘기본값 복원’으로 되돌릴 수 있습니다.
没有扫描目录 스캔 폴더가 없습니다
移除目录 폴더 제거
目录无效或已经添加 유효하지 않거나 이미 추가된 폴더입니다
添加目录… 폴더 추가…
恢复默认 기본값 복원
完成 완료
选择项目目录 프로젝트 폴더 선택
添加 추가
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 선택 항목에 최근 7일 안에 사용한 프로젝트가 있습니다. 정리 후 다시 빌드해야 합니다.
云盘项目可能同步删除。 클라우드 프로젝트는 다른 기기에서도 함께 삭제될 수 있습니다.
{} 项含手写或被 Git 跟踪的内容,已保护 {}개 항목은 직접 작성했거나 Git으로 추적되는 파일을 포함해 보호했습니다
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Xcode 명령줄 도구가 설치되어 있지 않아 Git 저장소의 {}개 항목을 확인하지 못해 유지했습니다
{} 项无法确认,已保留 {}개 항목은 확인할 수 없어 유지했습니다
1 # Copyright (C) 2026 ysicing
1512 应用版本:{}
1513 系统版本:{}
1514 请描述遇到的问题:
1515 项目产物
1516 按需扫描项目构建产物,逐项选择后移到废纸篓。
1517 正在扫描项目…
1518 扫描项目
1519 移到废纸篓 {}
1520 将 {} 项移到废纸篓,清空前可以恢复。
1521 已移到废纸篓 {} 项 · {}
1522 跳过 {} 项 · 失败 {} 项
1523 {} 个目录未完成扫描,结果已排除
1524 没有发现可清理的项目产物
1525 最近 7 天有活动
1526 超过 7 天未使用
1527 云盘
1528 Agent 工作树
1529 {} 项 · {}
1530 尚未扫描项目
1531 扫描位置
1532 扫描目录
1533 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1534 没有扫描目录
1535 移除目录
1536 目录无效或已经添加
1537 添加目录…
1538 恢复默认
1539 完成
1540 选择项目目录
1541 添加
1542 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1543 云盘项目可能同步删除。
1544 {} 项含手写或被 Git 跟踪的内容,已保护
1545 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1546 {} 项无法确认,已保留
@@ -1513,3 +1513,35 @@ XStats 反馈 · {} XStats 意見回饋 · {}
应用版本:{} App 版本:{}
系统版本:{} 系統版本:{}
请描述遇到的问题: 請描述遇到的問題:
项目产物 專案產物
按需扫描项目构建产物,逐项选择后移到废纸篓。 按需掃描專案建置產物,逐項選擇後移到廢紙簍。
正在扫描项目… 正在掃描專案…
扫描项目 掃描專案
移到废纸篓 {} 移到廢紙簍 {}
将 {} 项移到废纸篓,清空前可以恢复。 將 {} 項移到廢紙簍,清空前可以還原。
已移到废纸篓 {} 项 · {} 已移到廢紙簍 {} 項 · {}
跳过 {} 项 · 失败 {} 项 略過 {} 項 · 失敗 {} 項
{} 个目录未完成扫描,结果已排除 {} 個目錄未完成掃描,結果已排除
没有发现可清理的项目产物 沒有發現可清理的專案產物
最近 7 天有活动 最近 7 天有活動
超过 7 天未使用 超過 7 天未使用
云盘 雲端硬碟
Agent 工作树 Agent 工作樹
{} 项 · {} {} 項 · {}
尚未扫描项目 尚未掃描專案
扫描位置 掃描位置
扫描目录 掃描目錄
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 僅掃描列表中的目錄。移除後可透過「恢復預設」找回預設位置。
没有扫描目录 沒有掃描目錄
移除目录 移除目錄
目录无效或已经添加 目錄無效或已經新增
添加目录… 新增目錄…
恢复默认 恢復預設
完成 完成
选择项目目录 選擇專案目錄
添加 新增
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 所選內容包含最近 7 天有活動的專案,清理後需要重新建置。
云盘项目可能同步删除。 雲端硬碟中的專案可能同步刪除。
{} 项含手写或被 Git 跟踪的内容,已保护 {} 項含手寫或被 Git 追蹤的內容,已保護
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 未安裝 Xcode 命令列工具,無法確認 Git 儲存庫中的 {} 項是否被追蹤,已保留
{} 项无法确认,已保留 {} 項無法確認,已保留
1 # Copyright (c) 2026 GiantAccel, LLC
1513 应用版本:{}
1514 系统版本:{}
1515 请描述遇到的问题:
1516 项目产物
1517 按需扫描项目构建产物,逐项选择后移到废纸篓。
1518 正在扫描项目…
1519 扫描项目
1520 移到废纸篓 {}
1521 将 {} 项移到废纸篓,清空前可以恢复。
1522 已移到废纸篓 {} 项 · {}
1523 跳过 {} 项 · 失败 {} 项
1524 {} 个目录未完成扫描,结果已排除
1525 没有发现可清理的项目产物
1526 最近 7 天有活动
1527 超过 7 天未使用
1528 云盘
1529 Agent 工作树
1530 {} 项 · {}
1531 尚未扫描项目
1532 扫描位置
1533 扫描目录
1534 仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。
1535 没有扫描目录
1536 移除目录
1537 目录无效或已经添加
1538 添加目录…
1539 恢复默认
1540 完成
1541 选择项目目录
1542 添加
1543 所选内容包含最近 7 天有活动的项目,清理后需要重新构建。
1544 云盘项目可能同步删除。
1545 {} 项含手写或被 Git 跟踪的内容,已保护
1546 未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留
1547 {} 项无法确认,已保留
@@ -1363,5 +1363,37 @@ XStats 反馈 · {} XStats Feedback · {}
应用版本:{} App version: {}
系统版本:{} System version: {}
请描述遇到的问题: Describe the problem:
项目产物 Project Artifacts
按需扫描项目构建产物,逐项选择后移到废纸篓。 Scan project build artifacts on demand, then move selected items to Trash.
正在扫描项目… Scanning projects…
扫描项目 Scan Projects
移到废纸篓 {} Move {} to Trash
将 {} 项移到废纸篓,清空前可以恢复。 Move {} items to Trash; they can be restored until Trash is emptied.
已移到废纸篓 {} 项 · {} Moved {} items to Trash · {}
跳过 {} 项 · 失败 {} 项 Skipped {} · Failed {}
{} 个目录未完成扫描,结果已排除 {} directories could not be fully scanned; their results were excluded
没有发现可清理的项目产物 No project artifacts to clean
最近 7 天有活动 Activity in the last 7 days
超过 7 天未使用 Unused for over 7 days
云盘 Cloud storage
Agent 工作树 Agent worktree
{} 项 · {} {} items · {}
尚未扫描项目 Projects not scanned yet
扫描位置 Scan Locations
扫描目录 Scan Folders
仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 Only folders in this list are scanned. Restore Defaults brings back removed default locations.
没有扫描目录 No scan folders
移除目录 Remove Folder
目录无效或已经添加 The folder is invalid or already added
添加目录… Add Folder…
恢复默认 Restore Defaults
完成 Done
选择项目目录 Choose Project Folders
添加 Add
所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 The selection includes projects active in the last 7 days; they will need to be rebuilt.
云盘项目可能同步删除。 Cloud projects may be deleted on other devices too.
{} 项含手写或被 Git 跟踪的内容,已保护 {} items contain hand-written or Git-tracked files and are protected
未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Xcode Command Line Tools are not installed, so {} items in Git repositories could not be checked for tracked files and were kept
{} 项无法确认,已保留 {} items could not be verified and were kept
"""#
}
@@ -21,6 +21,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
private var aiUsageSettingsWindow: AIUsageSettingsWindowController!
private var updateWindow: UpdateWindowController!
private var speedTestWindow: SpeedTestWindowController!
private var projectPurgeWindow: ProjectPurgeWindowController!
private var egressWindow: EgressWindowController!
private var rest: RestController { model.rest }
private var restWindows: RestWindowController!
@@ -52,6 +53,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
updateWindow = UpdateWindowController(model: model)
speedTestWindow = SpeedTestWindowController(model: model)
speedTestWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
projectPurgeWindow = ProjectPurgeWindowController(model: model)
projectPurgeWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
egressWindow = EgressWindowController(model: model)
egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
restWindows = RestWindowController(rest: rest)
@@ -97,6 +100,11 @@ public final class AppController: NSObject, NSApplicationDelegate {
self?.calendarMenuBar.dismiss()
self?.speedTestWindow.show()
}
model.openProjectPurgeWindow = { [weak self] in
self?.menuBar.dismissPopovers()
self?.calendarMenuBar.dismiss()
self?.projectPurgeWindow.show()
}
model.quit = { NSApp.terminate(nil) }
model.helper.refreshStatus()
Task { await model.helper.verifyVersion() }
@@ -285,7 +293,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
/// 默认只在菜单栏运行、不占程序坞;打开了“在程序坞显示图标”时,有窗口开着才出现在程序坞与 ⌘Tab 里
private func updateActivationPolicy() {
let hasWindow = mainWindow.isVisible || aiUsageSettingsWindow.isVisible
|| egressWindow.isVisible || speedTestWindow.isVisible
|| egressWindow.isVisible || speedTestWindow.isVisible || projectPurgeWindow.isVisible
let policy: NSApplication.ActivationPolicy = hasWindow && model.settings.showDockIcon ? .regular : .accessory
guard NSApp.activationPolicy() != policy else { return }
NSApp.setActivationPolicy(policy)
@@ -507,6 +515,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
menuBar.dismissPopovers()
menuBar.refreshImages()
speedTestWindow.refreshLanguage()
projectPurgeWindow.refreshLanguage()
egressWindow.refreshLanguage()
aiUsageSettingsWindow.refreshLanguage()
updateWindow.refreshLanguage()
@@ -0,0 +1,68 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Localization
import SwiftUI
/// 项目产物只在独立窗口中扫描;关窗后停止尚未完成的只读扫描。
@MainActor
final class ProjectPurgeWindowController: NSObject, NSWindowDelegate {
private let model: AppModel
private var window: NSWindow?
private(set) var isVisible = false
var onVisibilityChange: ((Bool) -> Void)?
init(model: AppModel) {
self.model = model
super.init()
}
func refreshLanguage() { window?.title = tr("项目产物") }
func show() {
let window = self.window ?? makeWindow()
self.window = window
if window.isMiniaturized { window.deminiaturize(nil) }
if !isVisible {
isVisible = true
onVisibilityChange?(true)
}
NSApp.activate()
window.makeKeyAndOrderFront(nil)
}
private func makeWindow() -> NSWindow {
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: DS.Size.speedTestWindowWidth,
height: DS.Size.speedTestWindowHeight),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.title = tr("项目产物")
WindowChrome.apply(to: window)
window.isReleasedWhenClosed = false
window.delegate = self
window.contentMinSize = NSSize(width: DS.Size.panelWidth - DS.Space.s16,
height: DS.Size.panelMinHeight * 1.5)
window.contentView = NSHostingView(rootView: ProjectPurgeWindowView().environment(model))
if !window.setFrameUsingName("XStatsProjectPurgeWindow") { window.center() }
window.setFrameAutosaveName("XStatsProjectPurgeWindow")
return window
}
func windowWillClose(_ notification: Notification) {
isVisible = false
model.projectPurge.cancelScan()
onVisibilityChange?(false)
}
func windowDidMiniaturize(_ notification: Notification) {
isVisible = false
model.projectPurge.cancelScan()
onVisibilityChange?(false)
}
func windowDidDeminiaturize(_ notification: Notification) {
isVisible = true
onVisibilityChange?(true)
}
}
@@ -303,6 +303,16 @@ private struct PageHeader: View {
}
}
if tab == .memory { PurgeMemoryButton() }
if tab == .cleaner {
Button { model.openProjectPurgeWindow() } label: {
HStack(spacing: DS.Space.s1) {
Image(systemName: "shippingbox")
Text(tr("项目产物")).lineLimit(1).fixedSize()
}
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.help(tr("按需扫描项目构建产物,逐项选择后移到废纸篓。"))
}
if tab == .network {
Button { model.openSpeedTestWindow() } label: {
HStack(spacing: DS.Space.s1) {
@@ -0,0 +1,308 @@
// 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<String>()
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("在访达中显示"))
}
}
}
@@ -20,6 +20,7 @@ public final class AppModel {
public let fans: FanController
public let keepAwake: KeepAwakeController
public let cleaner: CleanerController
let projectPurge: ProjectPurgeController
public let maintenance: MaintenanceController
public let network: NetworkController
public let egress = EgressController()
@@ -66,6 +67,7 @@ public final class AppModel {
@ObservationIgnored var openMainWindow: (PanelTab?) -> Void = { _ in }
@ObservationIgnored var openEgressWindow: () -> Void = {}
@ObservationIgnored var openSpeedTestWindow: () -> Void = {}
@ObservationIgnored var openProjectPurgeWindow: () -> Void = {}
@ObservationIgnored var collapseToRestHUD: () -> Void = {}
@ObservationIgnored var quit: () -> Void = {}
@@ -84,6 +86,7 @@ public final class AppModel {
fans = FanController(helper: helper, store: store, settings: settings)
keepAwake = KeepAwakeController(helper: helper, settings: settings)
cleaner = CleanerController(settings: settings)
projectPurge = ProjectPurgeController(settings: settings)
maintenance = MaintenanceController(helper: helper)
network = NetworkController(settings: settings)
updates = UpdateController(settings: settings)
@@ -652,6 +652,16 @@ public final class AppSettings {
}
}
}
/// nil 使用动态默认目录;空数组表示用户明确移除了所有目录。仅在本机保存。
public var projectPurgeConfiguredPaths: [String]? {
didSet {
if let projectPurgeConfiguredPaths {
defaults.set(projectPurgeConfiguredPaths, forKey: Keys.projectPurgeConfiguredPaths)
} else {
defaults.removeObject(forKey: Keys.projectPurgeConfiguredPaths)
}
}
}
public static let refreshOptions = [1, 2, 3, 5]
public static let restWorkOptions = [20, 25, 30, 45, 60]
@@ -723,6 +733,7 @@ public final class AppSettings {
speedTestRoute = defaults.string(forKey: Keys.speedTestRoute).flatMap(SpeedRoute.init(rawValue:)) ?? .direct
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
cleanSelectedRuleIDs = defaults.stringArray(forKey: Keys.cleanSelectedRuleIDs).map(Set.init)
projectPurgeConfiguredPaths = defaults.stringArray(forKey: Keys.projectPurgeConfiguredPaths)
menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate
expandedPopoverSections = Set((defaults.stringArray(forKey: Keys.expandedPopoverSections) ?? []).compactMap(PopoverSection.init(rawValue:)))
hiddenPopoverSections = defaults.stringArray(forKey: Keys.hiddenPopoverSections)
@@ -821,6 +832,7 @@ public final class AppSettings {
static let speedTestRoute = "speedTestRoute"
static let cleanPrefersTrash = "cleanPrefersTrash"
static let cleanSelectedRuleIDs = "cleanSelectedRuleIDs"
static let projectPurgeConfiguredPaths = "projectPurgeConfiguredPaths"
static let menuBarLayout = "menuBarLayout"
static let hiddenPopoverSections = "hiddenPopoverSections"
static let expandedPopoverSections = "expandedPopoverSections"
@@ -0,0 +1,151 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Cleaner
import Foundation
import Observation
/// 项目扫描只由清理页的按钮启动,不参与普通缓存的自动扫描。
@MainActor
@Observable
final class ProjectPurgeController {
enum Phase { case idle, scanning, ready, cleaning, finished }
private(set) var phase: Phase = .idle
private(set) var result: ProjectPurgeScan?
private(set) var report: ProjectPurgeReport?
private(set) var roots: [URL] = []
private(set) var pathsLoaded = false
private(set) var isLoadingPaths = false
var selection: Set<String> = []
var isConfirming = false
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let home: String
@ObservationIgnored private var task: Task<Void, Never>?
init(settings: AppSettings, home: String = NSHomeDirectory()) {
self.settings = settings
self.home = home
if let configured = settings.projectPurgeConfiguredPaths {
roots = configured.map { URL(fileURLWithPath: $0) }
pathsLoaded = true
}
}
var isBusy: Bool { phase == .scanning || phase == .cleaning }
var isUsingDefaults: Bool { settings.projectPurgeConfiguredPaths == nil }
var selectedItems: [ProjectPurgeItem] { result?.items.filter { selection.contains($0.id) } ?? [] }
var selectedBytes: UInt64 { selectedItems.reduce(0) { $0 + $1.bytes } }
/// 只在打开目录设置时发现默认位置;磁盘遍历放在 utility 子任务,避免阻塞主线程。
func loadPaths() async {
guard !pathsLoaded, !isLoadingPaths else { return }
if let configured = settings.projectPurgeConfiguredPaths {
roots = configured.map { URL(fileURLWithPath: $0) }
pathsLoaded = true
return
}
isLoadingPaths = true
let home = home
let discovered = await withTaskGroup(of: [URL].self) { group in
group.addTask(priority: .utility) {
ProjectPurge.defaultSearchRoots(home: home)
}
return await group.next() ?? []
}
roots = discovered
pathsLoaded = true
isLoadingPaths = false
}
@discardableResult
func addRoot(_ url: URL) -> Bool {
guard pathsLoaded, !isLoadingPaths, !isBusy,
let root = ProjectPurge.validatedAdditionalRoot(url, home: home),
!roots.contains(where: { ProjectPurge.sameRoot($0, root) }) else { return false }
roots.insert(root, at: 0)
settings.projectPurgeConfiguredPaths = roots.map(\.path)
invalidateScan()
return true
}
func removeRoot(_ url: URL) {
guard pathsLoaded, !isLoadingPaths, !isBusy else { return }
let before = roots.count
roots.removeAll { ProjectPurge.sameRoot($0, url) }
guard roots.count != before else { return }
settings.projectPurgeConfiguredPaths = roots.map(\.path)
invalidateScan()
}
func restoreDefaults() async {
guard !isBusy, !isLoadingPaths else { return }
settings.projectPurgeConfiguredPaths = nil
roots = []
pathsLoaded = false
invalidateScan()
await loadPaths()
}
private func invalidateScan() {
selection.removeAll()
result = nil
report = nil
isConfirming = false
phase = .idle
}
func scan() {
guard !isBusy else { return }
phase = .scanning
isConfirming = false
selection.removeAll()
result = nil
report = nil
let configuredRoots = settings.projectPurgeConfiguredPaths.map { $0.map { URL(fileURLWithPath: $0) } }
?? (pathsLoaded ? roots : nil)
let home = home
task = Task {
let scan = await ProjectPurge.scan(home: home, configuredRoots: configuredRoots)
guard !Task.isCancelled else { phase = .idle; return }
result = scan
phase = .ready
}
}
func cancelScan() {
guard phase == .scanning else { return }
task?.cancel()
}
func toggle(_ item: ProjectPurgeItem) {
guard !isBusy else { return }
if selection.contains(item.id) { selection.remove(item.id) } else { selection.insert(item.id) }
isConfirming = false
}
func requestClean() {
guard !isBusy, !selectedItems.isEmpty else { return }
isConfirming = true
}
func confirmClean() {
guard isConfirming, let scan = result, !selectedItems.isEmpty else { return }
isConfirming = false
phase = .cleaning
let selected = selection
task = Task {
let report = await ProjectPurge.clean(scan, selected: selected)
self.report = report
// 只去掉已移到废纸篓的项,不整体重扫;被跳过的项保留,用户可随时重新扫描
result = scan.removing(report.trashedIDs)
selection.removeAll()
phase = .finished
}
}
func cancelClean() {
guard phase == .cleaning else { return }
task?.cancel()
}
}
@@ -0,0 +1,426 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import Cleaner
private struct ProjectHome {
let root: URL
init() throws {
root = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-project-purge-\(UUID())")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
@discardableResult
func file(_ path: String, age: TimeInterval = 30 * 86_400) throws -> URL {
let url = root.appendingPathComponent(path)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data(repeating: 0x41, count: 4096).write(to: url)
let modified = Date().addingTimeInterval(-age)
try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.path)
try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.deletingLastPathComponent().path)
return url
}
func remove() { try? FileManager.default.removeItem(at: root) }
}
@Suite("项目产物清理")
struct ProjectPurgeTests {
@Test func defaultSearchDoesNotHardcodeWork() throws {
let home = try ProjectHome()
defer { home.remove() }
let names = ["www", "dev", "Projects", "GitHub", "Code", "Workspace", "Repos",
"Development", "Library/CloudStorage", ".codex/worktrees", ".claude/worktrees"]
for name in names + ["Work"] {
try FileManager.default.createDirectory(at: home.root.appendingPathComponent(name), withIntermediateDirectories: true)
}
let roots = ProjectPurge.defaultSearchRoots(home: home.root.path)
#expect(Set(roots.map { $0.lastPathComponent }) == Set(names.map { ($0 as NSString).lastPathComponent }))
}
@Test func configuredRootFindsProjectOutsideDefaultLocations() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Work/github/ysicing/demo/package.json")
try home.file("Work/github/ysicing/demo/build/output.bin")
let extra = home.root.appendingPathComponent("Work")
let baseline = await ProjectPurge.scan(home: home.root.path)
let configured = await ProjectPurge.scan(home: home.root.path, configuredRoots: [extra])
#expect(baseline.items.isEmpty)
#expect(configured.items.map(\.url.lastPathComponent) == ["build"])
}
@Test func overlappingConfiguredRootsDoNotDuplicateArtifacts() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/demo/package.json")
try home.file("Projects/demo/build/output.bin")
let extra = home.root.appendingPathComponent("Projects/demo")
let scan = await ProjectPurge.scan(home: home.root.path,
configuredRoots: [home.root.appendingPathComponent("Projects"), extra])
#expect(scan.items.count == 1)
}
@Test func configuredRootsReplaceDefaultsIncludingAnEmptyList() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/default/package.json")
try home.file("Projects/default/build/output.bin")
try home.file("Work/github/ysicing/custom/package.json")
try home.file("Work/github/ysicing/custom/build/output.bin")
let custom = home.root.appendingPathComponent("Work")
let disabled = await ProjectPurge.scan(home: home.root.path, configuredRoots: [])
let configured = await ProjectPurge.scan(home: home.root.path, configuredRoots: [custom])
#expect(disabled.items.isEmpty)
#expect(configured.items.count == 1)
#expect(configured.items.first?.project.lastPathComponent == "custom")
}
@Test func dependencyDirectoryIsNotDiscoveredAsAProjectRoot() throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("node_modules/package/package.json")
try home.file("node_modules/package/dist/output.js")
let roots = ProjectPurge.defaultSearchRoots(home: home.root.path)
#expect(!roots.contains { $0.lastPathComponent == "node_modules" })
}
@Test func scanDoesNotDescendIntoUnownedDependencyDirectory() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/node_modules/package/package.json")
try home.file("Projects/node_modules/package/dist/output.js")
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(scan.items.isEmpty)
}
@Test func linkedDefaultRootIsReportedAsIncomplete() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("real-projects/app/package.json")
let link = home.root.appendingPathComponent("Projects")
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: home.root.appendingPathComponent("real-projects"))
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(scan.failedRoots.contains { $0.lastPathComponent == "Projects" })
}
@Test func scanFindsArtifactsAndExcludesAuthoredContent() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/app/package.json")
let dependency = try home.file("Projects/app/node_modules/package/index.js")
try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(-30 * 86_400)],
ofItemAtPath: dependency.deletingLastPathComponent().deletingLastPathComponent().path)
let recent = try home.file("Projects/app/dist/output.js", age: 3600)
try home.file("Projects/app/target/.git/config")
try home.file("Projects/app/.build/deploy-keypair.json")
try home.file("Projects/app/src/main.js")
let scan = await ProjectPurge.scan(home: home.root.path)
let paths = Set(scan.items.map(\.url.lastPathComponent))
#expect(paths == ["node_modules", "dist"])
// target 含 .git、.build 含部署密钥:计为受保护,而不是“无法确认”
#expect(scan.protectedCount == 2)
#expect(scan.unverifiedCount == 0)
#expect(scan.items.first { $0.url.lastPathComponent == "node_modules" }?.isRecent == false)
#expect(scan.items.first { $0.url.lastPathComponent == "dist" }?.isRecent == true)
#expect(scan.items.contains { $0.url.path == dependency.path || $0.url.path == recent.path } == false)
}
@Test func trackedBuildDirectoryIsProtected() async throws {
let home = try ProjectHome()
defer { home.remove() }
let project = home.root.appendingPathComponent("Projects/repo")
try home.file("Projects/repo/package.json")
try home.file("Projects/repo/build/tracked.txt")
try runGit(["init", "-q"], at: project)
try runGit(["add", "--", "build/tracked.txt"], at: project)
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(!scan.items.contains { $0.url.lastPathComponent == "build" })
#expect(scan.protectedCount == 1)
}
@Test func applicationBundlesAreNotDiscoveredOrScannedAsProjects() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Standalone.APP/package.json")
try home.file("Standalone.APP/node_modules/runtime/index.js")
try home.file("Downloads/demo/package.json")
try home.file("Downloads/demo/build/output.bin")
try home.file("Downloads/Example.app/Contents/Resources/app/package.json")
try home.file("Downloads/Example.app/Contents/Resources/app/node_modules/runtime/index.js")
let roots = ProjectPurge.defaultSearchRoots(home: home.root.path)
#expect(!roots.contains { $0.lastPathComponent == "Standalone.APP" })
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(scan.items.map { $0.project.lastPathComponent } == ["demo"])
}
@Test func applicationBundleRootsAndTheirContentsAreRejected() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Downloads/Example.APP/Contents/Resources/app/package.json")
try home.file("Downloads/Example.APP/Contents/Resources/app/dist/runtime.js")
let bundle = home.root.appendingPathComponent("Downloads/Example.APP")
let contents = bundle.appendingPathComponent("Contents/Resources/app")
let alias = home.root.appendingPathComponent("linked-runtime")
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: contents)
for root in [bundle, contents, alias] {
#expect(ProjectPurge.validatedAdditionalRoot(root, home: home.root.path) == nil)
}
let scan = await ProjectPurge.scan(home: home.root.path, configuredRoots: [bundle, contents])
#expect(scan.items.isEmpty)
}
@Test func contentStagedDuringBatchCleanupIsProtected() async throws {
let home = try ProjectHome()
defer { home.remove() }
let repo = home.root.appendingPathComponent("Projects/repo")
for name in ["a", "b"] {
try home.file("Projects/repo/\(name)/package.json")
try home.file("Projects/repo/\(name)/build/output.bin")
}
try runGit(["init", "-q"], at: repo)
let scan = await ProjectPurge.scan(home: home.root.path, configuredRoots: [repo])
#expect(scan.items.map { $0.project.lastPathComponent } == ["a", "b"])
let first = try #require(scan.items.first)
let report = await ProjectPurge.clean(scan, selected: Set(scan.items.map(\.id)), trash: { url in
if url == first.url {
// 模拟处理第一项期间用户暂存第二项;git add 不改变产物的 inode 或内容修改时间。
try runGit(["add", "-f", "--", "b/build/output.bin"], at: repo)
} else {
Issue.record("清理期间已被 Git 跟踪的文件不能移到废纸篓")
}
})
#expect(report.trashedIDs == [first.id])
#expect(report.skippedCount == 1)
}
@Test func oneGitQueryPerRepositoryStillSeparatesTrackedArtifacts() async throws {
let home = try ProjectHome()
defer { home.remove() }
let repo = home.root.appendingPathComponent("Projects/mono")
try home.file("Projects/mono/package.json")
for name in ["a", "b", "c"] {
try home.file("Projects/mono/packages/\(name)/package.json")
try home.file("Projects/mono/packages/\(name)/dist/index.js")
}
// 只有 b 的产物被 Git 跟踪;前缀相同的 dist-extra 被跟踪不应波及 dist
try home.file("Projects/mono/packages/a/dist-extra/keep.txt")
try runGit(["init", "-q"], at: repo)
// -f:开发者全局 gitignore 常忽略 dist,测试不能依赖本机配置
try runGit(["add", "-f", "--", "packages/b/dist/index.js", "packages/a/dist-extra/keep.txt"], at: repo)
let scan = await ProjectPurge.scan(home: home.root.path)
let dists = Set(scan.items.filter { $0.url.lastPathComponent == "dist" }.map { $0.project.lastPathComponent })
#expect(dists == ["a", "c"])
#expect(scan.protectedCount == 1)
}
@Test func missingDeveloperToolsKeepsRepositoryArtifactsWithoutRunningGit() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/repo/package.json")
try home.file("Projects/repo/.git/HEAD")
try home.file("Projects/repo/build/output.bin")
try home.file("Projects/plain/package.json")
try home.file("Projects/plain/build/output.bin")
let scan = await ProjectPurge.scan(home: home.root.path, now: Date(), configuredRoots: nil, developerTools: false)
// 不在仓库里的产物无需 git,照常列出;仓库里的全部保留并说明原因
#expect(scan.items.map { $0.project.lastPathComponent } == ["plain"])
#expect(scan.unverifiedCount == 1)
#expect(scan.developerToolsMissing)
let plain = try #require(scan.items.first)
let report = await ProjectPurge.clean(scan, selected: [plain.id], trash: { _ in }, developerTools: false)
#expect(report.trashedIDs == [plain.id])
}
@Test func developerToolsProbeTreatsXcodeSelectFailureAsMissing() throws {
let script = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-xcode-select-\(UUID())")
defer { try? FileManager.default.removeItem(at: script) }
// 未安装开发者工具时 xcode-select -p 以非零状态退出,不弹窗
try "#!/bin/sh\necho 'xcode-select: error: unable to get active developer directory' >&2\nexit 2\n"
.write(to: script, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path)
#expect(!ProjectPurge.developerToolsInstalled(xcodeSelect: script.path))
let wrongPath = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-xcode-select-\(UUID())")
defer { try? FileManager.default.removeItem(at: wrongPath) }
try "#!/bin/sh\necho /nonexistent/Developer\n".write(to: wrongPath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: wrongPath.path)
#expect(!ProjectPurge.developerToolsInstalled(xcodeSelect: wrongPath.path))
}
@Test func libraryAndHiddenFoldersAreNotAcceptedAsSearchRoots() throws {
let home = try ProjectHome()
defer { home.remove() }
func folder(_ path: String) throws -> URL {
let url = home.root.appendingPathComponent(path)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
// 应用数据和编辑器扩展里有 package.json,其中的 dist/ 被清掉会损坏它们
for rejected in ["Library/Application Support/Code", "Library/Developer/Xcode", ".vscode/extensions",
".codex/sessions", "Projects/.cache", "Library/CloudStorage/Drive/.hidden"] {
#expect(ProjectPurge.validatedAdditionalRoot(try folder(rejected), home: home.root.path) == nil, "\(rejected)")
}
for accepted in ["Library/CloudStorage/Drive", "Library/Mobile Documents/com~apple~CloudDocs",
".codex/worktrees/repo", ".claude/worktrees", "Work/github"] {
#expect(ProjectPurge.validatedAdditionalRoot(try folder(accepted), home: home.root.path) != nil, "\(accepted)")
}
}
@Test func savedLibraryRootIsNotScanned() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file(".vscode/extensions/ext/package.json")
try home.file(".vscode/extensions/ext/dist/extension.js")
let saved = home.root.appendingPathComponent(".vscode/extensions")
let scan = await ProjectPurge.scan(home: home.root.path, configuredRoots: [saved])
#expect(scan.items.isEmpty)
}
@Test func largestProjectsComeFirstAndCleanReportsTrashedIDs() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/a-small/package.json")
try home.file("Projects/a-small/build/one.bin")
try home.file("Projects/z-large/package.json")
for index in 0..<4 { try home.file("Projects/z-large/build/\(index).bin") }
try home.file("Projects/z-large/dist/one.bin")
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(scan.items.map { "\($0.project.lastPathComponent)/\($0.url.lastPathComponent)" }
== ["z-large/build", "z-large/dist", "a-small/build"])
let target = try #require(scan.items.first)
let report = await ProjectPurge.clean(scan, selected: [target.id], trash: { _ in })
#expect(report.trashedIDs == [target.id])
#expect(scan.removing(report.trashedIDs).items.map(\.id) == scan.items.dropFirst().map(\.id))
}
@Test func scanIncludesCloudAndAgentWorktrees() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Library/CloudStorage/provider/app/package.json")
try home.file("Library/CloudStorage/provider/app/build/output.bin")
try home.file(".codex/worktrees/repo/Cargo.toml")
try home.file(".codex/worktrees/repo/target/output.bin")
let scan = await ProjectPurge.scan(home: home.root.path)
#expect(scan.items.contains { $0.url.lastPathComponent == "build" && $0.isCloud })
#expect(scan.items.contains { $0.url.lastPathComponent == "target" && $0.isWorktree })
}
@Test func customICloudDriveRootIsMarkedAsCloud() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Library/Mobile Documents/provider/app/package.json")
try home.file("Library/Mobile Documents/provider/app/build/output.bin")
let iCloud = home.root.appendingPathComponent("Library/Mobile Documents")
let scan = await ProjectPurge.scan(home: home.root.path, configuredRoots: [iCloud])
#expect(scan.items.first { $0.url.lastPathComponent == "build" }?.isCloud == true)
}
@Test func selectedArtifactMovesToTrash() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/app/package.json")
let original = try home.file("Projects/app/build/output.bin")
let scan = await ProjectPurge.scan(home: home.root.path)
let item = try #require(scan.items.first { $0.url.lastPathComponent == "build" })
let destination = home.root.appendingPathComponent("test-trash")
let report = await ProjectPurge.clean(scan, selected: [item.id], trash: { url in
try FileManager.default.moveItem(at: url, to: destination)
})
#expect(report.trashedCount == 1)
#expect(report.trashedBytes > 0)
#expect(!FileManager.default.fileExists(atPath: original.path))
#expect(FileManager.default.fileExists(atPath: destination.appendingPathComponent("output.bin").path))
}
@Test func changedDirectoryCannotBeTrashedFromOldScan() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/app/package.json")
let original = try home.file("Projects/app/build/old.bin")
let scan = await ProjectPurge.scan(home: home.root.path)
let item = try #require(scan.items.first { $0.url.lastPathComponent == "build" })
let oldDirectory = original.deletingLastPathComponent()
try FileManager.default.moveItem(at: oldDirectory, to: oldDirectory.deletingLastPathComponent().appendingPathComponent("previous-build"))
let replacement = try home.file("Projects/app/build/new.bin")
let report = await ProjectPurge.clean(scan, selected: [item.id], trash: { _ in
Issue.record("被替换的目录不应进入废纸篓")
})
#expect(report.trashedCount == 0)
#expect(report.skippedCount == 1)
#expect(FileManager.default.fileExists(atPath: replacement.path))
}
@Test func changedScanRootCannotBeTrashed() async throws {
let home = try ProjectHome()
defer { home.remove() }
try home.file("Projects/app/package.json")
try home.file("Projects/app/build/old.bin")
let scan = await ProjectPurge.scan(home: home.root.path)
let item = try #require(scan.items.first { $0.url.lastPathComponent == "build" })
let root = home.root.appendingPathComponent("Projects")
try FileManager.default.moveItem(at: root, to: home.root.appendingPathComponent("old-projects"))
let replacement = try home.file("Projects/app/build/new.bin")
let report = await ProjectPurge.clean(scan, selected: [item.id], trash: { _ in
Issue.record("被替换的扫描根目录不应进入废纸篓")
})
#expect(report.trashedCount == 0)
#expect(report.skippedCount == 1)
#expect(FileManager.default.fileExists(atPath: replacement.path))
}
private func runGit(_ arguments: [String], at directory: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = ["-C", directory.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
}
}
@@ -306,6 +306,66 @@ private func isolatedDefaults() -> UserDefaults {
}
}
@MainActor
@Suite struct ProjectPurgePathTests {
@Test func defaultPathsCanBeRemovedAndRestored() async throws {
let defaults = isolatedDefaults()
let home = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-project-paths-\(UUID())")
let projects = home.appendingPathComponent("Projects")
let dev = home.appendingPathComponent("dev")
try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: dev, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: home) }
let controller = ProjectPurgeController(settings: AppSettings(defaults: defaults), home: home.path)
await controller.loadPaths()
#expect(Set(controller.roots.map(\.lastPathComponent)) == ["Projects", "dev"])
controller.removeRoot(projects)
#expect(controller.roots.map(\.lastPathComponent) == ["dev"])
let reloaded = ProjectPurgeController(settings: AppSettings(defaults: defaults), home: home.path)
await reloaded.loadPaths()
#expect(reloaded.roots.map(\.lastPathComponent) == ["dev"])
await reloaded.restoreDefaults()
#expect(Set(reloaded.roots.map(\.lastPathComponent)) == ["Projects", "dev"])
#expect(AppSettings(defaults: defaults).projectPurgeConfiguredPaths == nil)
}
@Test func addedRootsPersistAndDuplicatesAreRejected() async throws {
let defaults = isolatedDefaults()
let home = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-project-paths-\(UUID())")
let projects = home.appendingPathComponent("Projects")
let extra = home.appendingPathComponent("Work")
let extraAlias = home.appendingPathComponent("WorkAlias")
try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: extra, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: extraAlias, withDestinationURL: extra)
defer { try? FileManager.default.removeItem(at: home) }
let controller = ProjectPurgeController(settings: AppSettings(defaults: defaults), home: home.path)
await controller.loadPaths()
#expect(controller.addRoot(extra))
#expect(!controller.addRoot(extra))
#expect(!controller.addRoot(extraAlias))
#expect(!controller.addRoot(projects))
let reloaded = ProjectPurgeController(settings: AppSettings(defaults: defaults), home: home.path)
await reloaded.loadPaths()
#expect(Set(reloaded.roots.map(\.lastPathComponent)) == ["Projects", "Work"])
reloaded.removeRoot(extra)
#expect(reloaded.roots.map(\.lastPathComponent) == ["Projects"])
}
@Test func unsafeRootsAreRejected() async {
let controller = ProjectPurgeController(settings: AppSettings(defaults: isolatedDefaults()))
await controller.loadPaths()
#expect(!controller.addRoot(URL(fileURLWithPath: "/")))
#expect(!controller.addRoot(FileManager.default.homeDirectoryForCurrentUser))
#expect(!controller.addRoot(URL(fileURLWithPath: "/System")))
#expect(!controller.addRoot(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library")))
}
}
@Suite struct PanelTabTests {
@MainActor @Test func removedAssistantPageRestoresToGeneralSettings() {
let defaults = isolatedDefaults()
+1 -1
View File
@@ -27,7 +27,7 @@ XStats shows CPU, GPU, memory, disk, network, battery, temperature and fan activ
## Features
- **System monitoring:** choose menu-bar metrics and styles; inspect trends, processes and hardware in popovers or the main window.
- **System tools:** fan control, keep-awake, cache cleanup, app removal and startup-item management, with previews and confirmation for destructive actions.
- **System tools:** fan control, keep-awake, cache and project-artifact cleanup, app removal and startup-item management, with previews and confirmation for destructive actions.
- **Network tools:** inspect connections, DNS and public IPs; optionally check IP reputation and connectivity.
- **Optional modules:** a separate menu-bar calendar; a local Pomodoro timer with a multi-display eye-break curtain, instant skip and Mini HUD; plus local Codex / Claude Code session-token statistics and subscription quota checks.
- **Desktop widgets:** system overview, Pomodoro timer, AI quotas, festival/almanac calendar, full-month calendar, tomorrow's work status, IP cleanliness and public IP. AI, IP and calendar data come only from the main app's local cache.
+1 -1
View File
@@ -27,7 +27,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
## 主な機能
- **システム監視**:メニューバーの項目と表示形式を選び、ポップオーバーやメイン画面で推移、プロセス、ハードウェア情報を確認できます。
- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **システムツール**:ファン制御、スリープ防止、キャッシュとプロジェクト生成物の削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。
- **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。
- **任意の機能**:独立したメニューバーカレンダー、複数画面の目の休憩オーバーレイと Mini HUD を備えたローカルのポモドーロタイマー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。
- **デスクトップウィジェット**:システム概要、ポモドーロタイマー、AI 利用枠、祝祭日・暦注カレンダー、月間カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。
+1 -1
View File
@@ -27,7 +27,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
## 주요 기능
- **시스템 모니터링**: 메뉴 막대 지표와 표시 방식을 선택하고 팝오버 또는 메인 창에서 추이, 프로세스, 하드웨어 정보를 확인합니다.
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 및 프로젝트 빌드 산출물 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다.
- **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다.
- **선택 기능**: 독립적인 메뉴 막대 달력, 다중 화면 눈 휴식 오버레이와 Mini HUD가 있는 로컬 뽀모도로 타이머, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회.
- **데스크톱 위젯**: 시스템 개요, 뽀모도로 타이머, AI 구독 한도, 명절·황력 달력, 월간 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다.
+1 -1
View File
@@ -27,7 +27,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
## 功能
- **系统监控**:可自选菜单栏指标与显示样式,在弹窗或主窗口查看趋势、进程和硬件信息。
- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **系统工具**:风扇控制、防休眠、缓存与项目产物清理、应用卸载和启动项管理;危险操作先预览、再确认。
- **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。
- **可选模块**:独立菜单栏日历;本机番茄钟与多屏护眼休息幕布(可随时跳过、暂停或收起到 Mini HUD);Codex / Claude Code 本机会话 Token 统计和订阅额度查询。
- **桌面小组件**:系统概览、番茄钟、AI 额度、节日黄历日历、整月日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。
+5
View File
@@ -8,6 +8,11 @@ XStats 新增代码与修改采用 AGPL-3.0-or-later,范围见 [LICENSING.md](
的时间/模型筛选、总览、趋势与模型排行,以及 OpenUsage 的本地会话日志解析边界。
本轮 SwiftUI 界面与本机扫描器为 XStats 独立实现,未引入 CC Switch 的 Rust/React 源码或依赖。
## 项目产物清理范围参考
项目产物的候选目录、默认搜索位置及安全检查参考 [tw93/Mole 的 `mo purge`](https://github.com/tw93/Mole)(GPL-3.0)。
XStats 使用独立编写的 Swift 扫描与废纸篓流程,未引入 Mole 的 Shell 源码或运行时依赖。
## 粉红噪音滤波参考
番茄钟的粉红噪音使用 [Paul Kellett 的滤波系数](https://www.musicdsp.org/en/latest/Filters/76-pink-noise-filter.html)