Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

## [Unreleased]

暂无未发布变更。
### Fixed

- 清理页的大型候选分组改为每页最多 40 项的固定分页;展开数百个媒体或重复文件时不再一次性加入 SwiftUI 视图树,避免单核满载、内存暴涨和界面假死。

## [0.7.9] - 2026-08-27

Expand Down
4 changes: 4 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"只读监测" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Read-only monitoring" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "唯讀監測" } } } },
"正在采样" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sampling" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "正在採樣" } } } },
"把可重建缓存、桌面/下载安装包、个人媒体与重复副本分开判断。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Evaluate rebuildable caches, installers from Desktop and Downloads, personal media, and duplicate copies separately." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "把可重建快取、桌面/下載安裝套件、個人媒體與重複副本分開判斷。" } } } },
"当前显示 %d–%d / %d 项。为避免大型列表卡住,每页最多加载 %d 项。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Showing %d–%d of %d items. To keep large lists responsive, each page loads at most %d items." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "目前顯示 %d–%d / %d 項。為避免大型清單卡住,每頁最多載入 %d 項。" } } } },
"上一页" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous Page" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "上一頁" } } } },
"第 %d / %d 页" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Page %d of %d" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "第 %d / %d 頁" } } } },
"下一页" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Next Page" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "下一頁" } } } },
"安装介质需要处理" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Installer Media Needs Attention" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "安裝媒體需要處理" } } } },
"安装介质已更新" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Installer Media Updated" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "安裝媒體已更新" } } } },
"CleanNest 会检查缓存、桌面/下载安装包、已挂载安装介质和大型媒体;启用媒体分析时还会用本机 SHA-256 查找字节完全相同的副本。扫描不会改动文件。" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "CleanNest checks caches, installers on Desktop and in Downloads, mounted installer media, and large media. When media analysis is enabled, it also uses local SHA-256 to find byte-identical copies. Scanning never changes files." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", "value" : "CleanNest 會檢查快取、桌面/下載安裝套件、已掛載安裝媒體和大型媒體;啟用媒體分析時也會用本機 SHA-256 尋找位元組完全相同的副本。掃描不會變更檔案。" } } } },
Expand Down
90 changes: 83 additions & 7 deletions Sources/CleanNest/CleanupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -632,16 +632,28 @@ private struct EvidenceMetric: View {
}

private struct CleanupGroup: View {
private static let autoExpandLimit = 24

@EnvironmentObject private var store: AppStore
let kind: CleanupKind
let candidates: [CleanupCandidate]
@State private var isExpanded: Bool
@State private var pageIndex: Int

private var total: Int64 { candidates.reduce(0) { $0 + $1.allocatedBytes } }
private var selectedCount: Int {
candidates.reduce(0) { $0 + (store.selectedCleanupIDs.contains($1.id) ? 1 : 0) }
}
private var allSelected: Bool { !candidates.isEmpty && selectedCount == candidates.count }
private var pageWindow: CleanupCandidatePageWindow {
CleanupCandidatePageWindow(
itemCount: candidates.count,
requestedPageIndex: pageIndex
)
}
private var visibleCandidates: [CleanupCandidate] {
Array(candidates[pageWindow.range])
}
private var showsMediaClusters: Bool {
kind == .screenshot || kind == .image || kind == .video || kind == .audio
}
Expand Down Expand Up @@ -671,17 +683,17 @@ private struct CleanupGroup: View {
init(kind: CleanupKind, candidates: [CleanupCandidate]) {
self.kind = kind
self.candidates = candidates
_isExpanded = State(initialValue: candidates.count <= 24)
_isExpanded = State(initialValue: candidates.count <= Self.autoExpandLimit)
_pageIndex = State(initialValue: 0)
}

var body: some View {
SurfaceCard {
VStack(spacing: 0) {
HStack(spacing: 10) {
Button {
withAnimation(.easeInOut(duration: 0.18)) {
isExpanded.toggle()
}
pageIndex = 0
isExpanded.toggle()
} label: {
HStack {
Image(systemName: kind.symbolName)
Expand Down Expand Up @@ -760,15 +772,79 @@ private struct CleanupGroup: View {
Divider()
.padding(.top, 10)

LazyVStack(spacing: 0) {
ForEach(Array(candidates.enumerated()), id: \.element.id) { index, candidate in
VStack(spacing: 0) {
ForEach(visibleCandidates) { candidate in
CleanupRow(candidate: candidate)
if index < candidates.count - 1 { Divider().padding(.leading, 38) }
if candidate.id != visibleCandidates.last?.id {
Divider().padding(.leading, 38)
}
}
}

if pageWindow.pageCount > 1 {
Divider()
cleanupPagination
}
}
}
}
.onChange(of: candidates.count) { _, _ in
pageIndex = pageWindow.pageIndex
}
}

private var cleanupPagination: some View {
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
paginationExplanation
Spacer(minLength: 12)
paginationControls
}
VStack(alignment: .leading, spacing: 9) {
paginationExplanation
paginationControls
}
}
.padding(.top, 10)
}

private var paginationExplanation: some View {
Text(
CNL10n.format(
"当前显示 %d–%d / %d 项。为避免大型列表卡住,每页最多加载 %d 项。",
pageWindow.displayedStartNumber,
pageWindow.displayedEndNumber,
candidates.count,
pageWindow.pageSize
)
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

private var paginationControls: some View {
HStack(spacing: 8) {
Button {
pageIndex = max(0, pageWindow.pageIndex - 1)
} label: {
Label(CNL10n.string("上一页"), systemImage: "chevron.left")
}
.disabled(!pageWindow.hasPreviousPage)

Text(CNL10n.format("第 %d / %d 页", pageWindow.pageIndex + 1, pageWindow.pageCount))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)

Button {
pageIndex = min(pageWindow.pageCount - 1, pageWindow.pageIndex + 1)
} label: {
Label(CNL10n.string("下一页"), systemImage: "chevron.right")
}
.disabled(!pageWindow.hasNextPage)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}

Expand Down
38 changes: 38 additions & 0 deletions Sources/CleanNestCore/CleanupSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,44 @@ public enum CleanupSelectionPolicy {
}
}

public struct CleanupCandidatePageWindow: Equatable, Sendable {
public static let defaultPageSize = 40

public let itemCount: Int
public let pageSize: Int
public let pageIndex: Int

public init(
itemCount: Int,
pageSize: Int = Self.defaultPageSize,
requestedPageIndex: Int = 0
) {
self.itemCount = max(0, itemCount)
self.pageSize = max(1, pageSize)
let availablePages = self.itemCount == 0
? 0
: ((self.itemCount - 1) / self.pageSize) + 1
self.pageIndex = availablePages == 0
? 0
: min(max(0, requestedPageIndex), availablePages - 1)
}

public var pageCount: Int {
itemCount == 0 ? 0 : ((itemCount - 1) / pageSize) + 1
}

public var range: Range<Int> {
guard itemCount > 0 else { return 0..<0 }
let lowerBound = pageIndex * pageSize
return lowerBound..<min(itemCount, lowerBound + pageSize)
}

public var displayedStartNumber: Int { range.isEmpty ? 0 : range.lowerBound + 1 }
public var displayedEndNumber: Int { range.upperBound }
public var hasPreviousPage: Bool { pageIndex > 0 }
public var hasNextPage: Bool { pageIndex + 1 < pageCount }
}

public struct CleanupBasketSummary: Equatable, Sendable {
public let selectedCount: Int
public let selectedBytes: Int64
Expand Down
31 changes: 31 additions & 0 deletions Tests/CleanNestCoreTests/CleanupSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ final class CleanupSessionTests: XCTestCase {
)
}

func testCandidatePageWindowNeverExposesAnUnboundedLargeGroup() {
let first = CleanupCandidatePageWindow(itemCount: 408)
XCTAssertEqual(first.pageCount, 11)
XCTAssertEqual(first.pageIndex, 0)
XCTAssertEqual(first.range, 0..<40)
XCTAssertEqual(first.displayedStartNumber, 1)
XCTAssertEqual(first.displayedEndNumber, 40)
XCTAssertFalse(first.hasPreviousPage)
XCTAssertTrue(first.hasNextPage)

let middle = CleanupCandidatePageWindow(itemCount: 408, requestedPageIndex: 5)
XCTAssertEqual(middle.range, 200..<240)
XCTAssertTrue(middle.hasPreviousPage)
XCTAssertTrue(middle.hasNextPage)

let last = CleanupCandidatePageWindow(itemCount: 408, requestedPageIndex: 999)
XCTAssertEqual(last.pageIndex, 10)
XCTAssertEqual(last.range, 400..<408)
XCTAssertEqual(last.displayedStartNumber, 401)
XCTAssertEqual(last.displayedEndNumber, 408)
XCTAssertTrue(last.hasPreviousPage)
XCTAssertFalse(last.hasNextPage)

let empty = CleanupCandidatePageWindow(itemCount: 0, requestedPageIndex: 9)
XCTAssertEqual(empty.pageCount, 0)
XCTAssertEqual(empty.pageIndex, 0)
XCTAssertEqual(empty.range, 0..<0)
XCTAssertFalse(empty.hasPreviousPage)
XCTAssertFalse(empty.hasNextPage)
}

func testBasketSeparatesSelectionRecoveryAndRevalidationEvidence() {
let retainedIdentity = CleanupFileIdentity(
deviceID: 1,
Expand Down