From 147f94ca701cdd393ee47fb06d4a2300b1c59589 Mon Sep 17 00:00:00 2001 From: ysicing Date: Mon, 28 Sep 2026 02:55:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(cleaner):=20=E6=96=B0=E5=A2=9E=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E4=BA=A7=E7=89=A9=E6=B8=85=E7=90=86=E4=B8=8E=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E7=9B=AE=E5=BD=95=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 2 + .../Sources/Cleaner/CleanEngine.swift | 10 +- .../Sources/Cleaner/ProjectPurge.swift | 580 ++++++++++++++++++ .../Sources/Localization/Resources/ar.tsv | 32 + .../Sources/Localization/Resources/de.tsv | 32 + .../Sources/Localization/Resources/es.tsv | 32 + .../Sources/Localization/Resources/fr.tsv | 32 + .../Sources/Localization/Resources/ja.tsv | 32 + .../Sources/Localization/Resources/ko.tsv | 32 + .../Localization/Resources/zh-Hant.tsv | 32 + .../Sources/Localization/Translations.swift | 32 + .../XStatsUI/AppKit/AppController.swift | 11 +- .../AppKit/ProjectPurgeWindowController.swift | 68 ++ .../XStatsUI/Panel/MainWindowView.swift | 10 + .../Panel/ProjectPurgeWindowView.swift | 308 ++++++++++ .../Sources/XStatsUI/State/AppModel.swift | 3 + .../Sources/XStatsUI/State/AppSettings.swift | 12 + .../State/ProjectPurgeController.swift | 151 +++++ .../CleanerTests/ProjectPurgeTests.swift | 426 +++++++++++++ .../Tests/XStatsUITests/XStatsUITests.swift | 60 ++ README.en.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- ThirdPartyNotices.md | 5 + 25 files changed, 1902 insertions(+), 8 deletions(-) create mode 100644 Packages/XStatsKit/Sources/Cleaner/ProjectPurge.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/AppKit/ProjectPurgeWindowController.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Panel/ProjectPurgeWindowView.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/State/ProjectPurgeController.swift create mode 100644 Packages/XStatsKit/Tests/CleanerTests/ProjectPurgeTests.swift diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ffd3f59..be1e972 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift index 0fafa69..881a195 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/Cleaner/ProjectPurge.swift b/Packages/XStatsKit/Sources/Cleaner/ProjectPurge.swift new file mode 100644 index 0000000..e8c5837 --- /dev/null +++ b/Packages/XStatsKit/Sources/Cleaner/ProjectPurge.swift @@ -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) -> 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 = [] + 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 = [ + "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 = [ + ".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) 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, + 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() + 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() + var unresolvedPaths = Set() + 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 = [.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..() + 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 + "/") + } +} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index c311279..643cc7c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1512,3 +1512,35 @@ XStats 反馈 · {} ملاحظات XStats · {} 应用版本:{} إصدار التطبيق: {} 系统版本:{} إصدار النظام: {} 请描述遇到的问题: صف المشكلة: +项目产物 مخرجات المشاريع +按需扫描项目构建产物,逐项选择后移到废纸篓。 افحص مخرجات بناء المشاريع عند الطلب، ثم انقل العناصر المحددة إلى سلة المهملات. +正在扫描项目… جارٍ فحص المشاريع… +扫描项目 فحص المشاريع +移到废纸篓 {} نقل {} إلى سلة المهملات +将 {} 项移到废纸篓,清空前可以恢复。 نقل {} عنصرًا إلى سلة المهملات؛ يمكن استعادتها قبل إفراغها. +已移到废纸篓 {} 项 · {} نُقل {} عنصرًا إلى سلة المهملات · {} +跳过 {} 项 · 失败 {} 项 تم تخطي {} · فشل {} +{} 个目录未完成扫描,结果已排除 تعذر إكمال فحص {} دليلًا؛ استُبعدت نتائجها +没有发现可清理的项目产物 لم يُعثر على مخرجات مشاريع قابلة للتنظيف +最近 7 天有活动 نشاط خلال آخر 7 أيام +超过 7 天未使用 غير مستخدم منذ أكثر من 7 أيام +云盘 تخزين سحابي +Agent 工作树 شجرة عمل Agent +{} 项 · {} {} عناصر · {} +尚未扫描项目 لم تُفحص المشاريع بعد +扫描位置 مواقع الفحص +扫描目录 مجلدات الفحص +仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 سيتم فحص المجلدات في هذه القائمة فقط. يعيد «استعادة الافتراضي» المواقع الافتراضية التي أُزيلت. +没有扫描目录 لا توجد مجلدات للفحص +移除目录 إزالة المجلد +目录无效或已经添加 المجلد غير صالح أو تمت إضافته مسبقًا +添加目录… إضافة مجلد… +恢复默认 استعادة الافتراضي +完成 تم +选择项目目录 اختيار مجلدات المشاريع +添加 إضافة +所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 يتضمن التحديد مشاريع نشطة خلال آخر 7 أيام؛ ستحتاج إلى إعادة بنائها. +云盘项目可能同步删除。 قد تُحذف مشاريع السحابة من أجهزتك الأخرى أيضًا. +{} 项含手写或被 Git 跟踪的内容,已保护 {} عنصر يحتوي على ملفات مكتوبة يدويًا أو يتتبعها Git، لذا تمت حمايته +未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 أدوات سطر الأوامر في Xcode غير مثبّتة، لذا تعذّر فحص {} عنصر في مستودعات Git وتم الاحتفاظ به +{} 项无法确认,已保留 تعذّر التحقق من {} عنصر وتم الاحتفاظ به diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index ae36ba3..9432967 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 48b1e09..3715216 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 7a9b1e5..948569f 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 3b5d3ca..147b368 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1512,3 +1512,35 @@ XStats 反馈 · {} XStats フィードバック · {} 应用版本:{} アプリのバージョン:{} 系统版本:{} システムのバージョン:{} 请描述遇到的问题: 発生した問題を記入してください: +项目产物 プロジェクト生成物 +按需扫描项目构建产物,逐项选择后移到废纸篓。 必要なときにプロジェクトの生成物をスキャンし、選択した項目をゴミ箱に移動します。 +正在扫描项目… プロジェクトをスキャン中… +扫描项目 プロジェクトをスキャン +移到废纸篓 {} {} をゴミ箱に移動 +将 {} 项移到废纸篓,清空前可以恢复。 {} 項目をゴミ箱に移動します。ゴミ箱を空にする前なら復元できます。 +已移到废纸篓 {} 项 · {} {} 項目をゴミ箱に移動 · {} +跳过 {} 项 · 失败 {} 项 スキップ {} 件 · 失敗 {} 件 +{} 个目录未完成扫描,结果已排除 {} 個のディレクトリを完全にスキャンできず、結果を除外しました +没有发现可清理的项目产物 削除可能なプロジェクト生成物はありません +最近 7 天有活动 過去7日間に使用 +超过 7 天未使用 7日以上未使用 +云盘 クラウドストレージ +Agent 工作树 Agentワークツリー +{} 项 · {} {} 項目 · {} +尚未扫描项目 プロジェクトはまだスキャンされていません +扫描位置 スキャン場所 +扫描目录 スキャンフォルダ +仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 この一覧のフォルダだけをスキャンします。削除した既定の場所は「既定に戻す」で復元できます。 +没有扫描目录 スキャンフォルダがありません +移除目录 フォルダを削除 +目录无效或已经添加 フォルダが無効か、すでに追加されています +添加目录… フォルダを追加… +恢复默认 既定に戻す +完成 完了 +选择项目目录 プロジェクトフォルダを選択 +添加 追加 +所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 選択に最近 7 日以内に使用したプロジェクトが含まれています。クリーンアップ後は再ビルドが必要です。 +云盘项目可能同步删除。 クラウド上のプロジェクトは同期先でも削除される場合があります。 +{} 项含手写或被 Git 跟踪的内容,已保护 {} 件は手書きのファイルまたは Git で管理されているファイルを含むため保護しました +未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Xcode コマンドラインツールがインストールされていないため、Git リポジトリ内の {} 件を確認できず、保持しました +{} 项无法确认,已保留 {} 件は確認できなかったため保持しました diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 39b6602..c5fa816 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1512,3 +1512,35 @@ XStats 反馈 · {} XStats 피드백 · {} 应用版本:{} 앱 버전: {} 系统版本:{} 시스템 버전: {} 请描述遇到的问题: 발생한 문제를 설명해 주세요: +项目产物 프로젝트 빌드 산출물 +按需扫描项目构建产物,逐项选择后移到废纸篓。 필요할 때 프로젝트 빌드 산출물을 스캔하고 선택한 항목을 휴지통으로 이동합니다. +正在扫描项目… 프로젝트 스캔 중… +扫描项目 프로젝트 스캔 +移到废纸篓 {} {} 휴지통으로 이동 +将 {} 项移到废纸篓,清空前可以恢复。 {}개 항목을 휴지통으로 이동합니다. 휴지통을 비우기 전까지 복원할 수 있습니다. +已移到废纸篓 {} 项 · {} {}개 항목을 휴지통으로 이동 · {} +跳过 {} 项 · 失败 {} 项 건너뜀 {}개 · 실패 {}개 +{} 个目录未完成扫描,结果已排除 {}개 디렉터리의 스캔이 완료되지 않아 결과에서 제외했습니다 +没有发现可清理的项目产物 정리할 프로젝트 산출물이 없습니다 +最近 7 天有活动 최근 7일 내 활동 +超过 7 天未使用 7일 이상 미사용 +云盘 클라우드 저장소 +Agent 工作树 Agent 작업 트리 +{} 项 · {} {}개 항목 · {} +尚未扫描项目 프로젝트를 아직 스캔하지 않았습니다 +扫描位置 스캔 위치 +扫描目录 스캔 폴더 +仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 이 목록의 폴더만 스캔합니다. 제거한 기본 위치는 ‘기본값 복원’으로 되돌릴 수 있습니다. +没有扫描目录 스캔 폴더가 없습니다 +移除目录 폴더 제거 +目录无效或已经添加 유효하지 않거나 이미 추가된 폴더입니다 +添加目录… 폴더 추가… +恢复默认 기본값 복원 +完成 완료 +选择项目目录 프로젝트 폴더 선택 +添加 추가 +所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 선택 항목에 최근 7일 안에 사용한 프로젝트가 있습니다. 정리 후 다시 빌드해야 합니다. +云盘项目可能同步删除。 클라우드 프로젝트는 다른 기기에서도 함께 삭제될 수 있습니다. +{} 项含手写或被 Git 跟踪的内容,已保护 {}개 항목은 직접 작성했거나 Git으로 추적되는 파일을 포함해 보호했습니다 +未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 Xcode 명령줄 도구가 설치되어 있지 않아 Git 저장소의 {}개 항목을 확인하지 못해 유지했습니다 +{} 项无法确认,已保留 {}개 항목은 확인할 수 없어 유지했습니다 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index c3c71dc..e9a87e4 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1513,3 +1513,35 @@ XStats 反馈 · {} XStats 意見回饋 · {} 应用版本:{} App 版本:{} 系统版本:{} 系統版本:{} 请描述遇到的问题: 請描述遇到的問題: +项目产物 專案產物 +按需扫描项目构建产物,逐项选择后移到废纸篓。 按需掃描專案建置產物,逐項選擇後移到廢紙簍。 +正在扫描项目… 正在掃描專案… +扫描项目 掃描專案 +移到废纸篓 {} 移到廢紙簍 {} +将 {} 项移到废纸篓,清空前可以恢复。 將 {} 項移到廢紙簍,清空前可以還原。 +已移到废纸篓 {} 项 · {} 已移到廢紙簍 {} 項 · {} +跳过 {} 项 · 失败 {} 项 略過 {} 項 · 失敗 {} 項 +{} 个目录未完成扫描,结果已排除 {} 個目錄未完成掃描,結果已排除 +没有发现可清理的项目产物 沒有發現可清理的專案產物 +最近 7 天有活动 最近 7 天有活動 +超过 7 天未使用 超過 7 天未使用 +云盘 雲端硬碟 +Agent 工作树 Agent 工作樹 +{} 项 · {} {} 項 · {} +尚未扫描项目 尚未掃描專案 +扫描位置 掃描位置 +扫描目录 掃描目錄 +仅扫描列表中的目录。移除后可通过“恢复默认”找回默认位置。 僅掃描列表中的目錄。移除後可透過「恢復預設」找回預設位置。 +没有扫描目录 沒有掃描目錄 +移除目录 移除目錄 +目录无效或已经添加 目錄無效或已經新增 +添加目录… 新增目錄… +恢复默认 恢復預設 +完成 完成 +选择项目目录 選擇專案目錄 +添加 新增 +所选内容包含最近 7 天有活动的项目,清理后需要重新构建。 所選內容包含最近 7 天有活動的專案,清理後需要重新建置。 +云盘项目可能同步删除。 雲端硬碟中的專案可能同步刪除。 +{} 项含手写或被 Git 跟踪的内容,已保护 {} 項含手寫或被 Git 追蹤的內容,已保護 +未安装 Xcode 命令行工具,无法确认 Git 仓库中的 {} 项是否被跟踪,已保留 未安裝 Xcode 命令列工具,無法確認 Git 儲存庫中的 {} 項是否被追蹤,已保留 +{} 项无法确认,已保留 {} 項無法確認,已保留 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index d574d57..01b1fc4 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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 """# } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 0fcf6e1..00c829f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -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() diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/ProjectPurgeWindowController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/ProjectPurgeWindowController.swift new file mode 100644 index 0000000..cf9f6a2 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/ProjectPurgeWindowController.swift @@ -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) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index 85b3c7b..2471fab 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -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) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/ProjectPurgeWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/ProjectPurgeWindowView.swift new file mode 100644 index 0000000..d89ad6b --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/ProjectPurgeWindowView.swift @@ -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() + 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("在访达中显示")) + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index d98e849..4125cf5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -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) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index d2406f7..b24f482 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -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" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/ProjectPurgeController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/ProjectPurgeController.swift new file mode 100644 index 0000000..aea52f8 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/State/ProjectPurgeController.swift @@ -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 = [] + var isConfirming = false + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private let home: String + @ObservationIgnored private var task: Task? + + 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() + } +} diff --git a/Packages/XStatsKit/Tests/CleanerTests/ProjectPurgeTests.swift b/Packages/XStatsKit/Tests/CleanerTests/ProjectPurgeTests.swift new file mode 100644 index 0000000..3e9156c --- /dev/null +++ b/Packages/XStatsKit/Tests/CleanerTests/ProjectPurgeTests.swift @@ -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) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index c9c9a85..d7b8282 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -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() diff --git a/README.en.md b/README.en.md index 991ab0d..1bea149 100644 --- a/README.en.md +++ b/README.en.md @@ -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. diff --git a/README.ja.md b/README.ja.md index eddd3a4..27139bc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -27,7 +27,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ ## 主な機能 - **システム監視**:メニューバーの項目と表示形式を選び、ポップオーバーやメイン画面で推移、プロセス、ハードウェア情報を確認できます。 -- **システムツール**:ファン制御、スリープ防止、キャッシュ削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。 +- **システムツール**:ファン制御、スリープ防止、キャッシュとプロジェクト生成物の削除、アプリの削除、起動項目の管理。削除前には内容を確認できます。 - **ネットワークツール**:接続、DNS、公開 IP を確認し、必要に応じて IP 評価や疎通を調べます。 - **任意の機能**:独立したメニューバーカレンダー、複数画面の目の休憩オーバーレイと Mini HUD を備えたローカルのポモドーロタイマー、Codex / Claude Code のローカルセッションの Token 統計とサブスクリプション利用枠の照会。 - **デスクトップウィジェット**:システム概要、ポモドーロタイマー、AI 利用枠、祝祭日・暦注カレンダー、月間カレンダー、明日の勤務予定、IP の信頼度、公開 IP。AI・IP・カレンダーのデータはメインアプリのローカルキャッシュから読み取ります。 diff --git a/README.ko.md b/README.ko.md index 6aa3fde..8254baa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -27,7 +27,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬 ## 주요 기능 - **시스템 모니터링**: 메뉴 막대 지표와 표시 방식을 선택하고 팝오버 또는 메인 창에서 추이, 프로세스, 하드웨어 정보를 확인합니다. -- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다. +- **시스템 도구**: 팬 제어, 잠자기 방지, 캐시 및 프로젝트 빌드 산출물 정리, 앱 제거, 시작 항목 관리. 삭제 전에는 대상 확인 단계가 있습니다. - **네트워크 도구**: 연결, DNS, 공인 IP를 확인하고 필요할 때 IP 평판과 연결 상태를 검사합니다. - **선택 기능**: 독립적인 메뉴 막대 달력, 다중 화면 눈 휴식 오버레이와 Mini HUD가 있는 로컬 뽀모도로 타이머, Codex / Claude Code 로컬 세션의 토큰 통계와 구독 한도 조회. - **데스크톱 위젯**: 시스템 개요, 뽀모도로 타이머, AI 구독 한도, 명절·황력 달력, 월간 달력, 내일 근무 여부, IP 신뢰도, 공인 IP. AI·IP·달력 데이터는 메인 앱의 로컬 캐시에서만 읽습니다. diff --git a/README.md b/README.md index 0c671c9..12bdcf3 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度 ## 功能 - **系统监控**:可自选菜单栏指标与显示样式,在弹窗或主窗口查看趋势、进程和硬件信息。 -- **系统工具**:风扇控制、防休眠、缓存清理、应用卸载和启动项管理;危险操作先预览、再确认。 +- **系统工具**:风扇控制、防休眠、缓存与项目产物清理、应用卸载和启动项管理;危险操作先预览、再确认。 - **网络工具**:查看连接、DNS 和公网 IP;按需检测 IP 纯净度与连接情况。 - **可选模块**:独立菜单栏日历;本机番茄钟与多屏护眼休息幕布(可随时跳过、暂停或收起到 Mini HUD);Codex / Claude Code 本机会话 Token 统计和订阅额度查询。 - **桌面小组件**:系统概览、番茄钟、AI 额度、节日黄历日历、整月日历、明天上班吗、IP 纯净度和公网 IP;AI、IP 与日历数据只读取主应用的本地缓存。 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 009a710..fdaee3e 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -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)