From 3e0c81ac4681907f6ec10ec365eb3c55bc61b73f Mon Sep 17 00:00:00 2001
From: Yuchen Lin <381564291@qq.com>
Date: Sat, 11 Jul 2026 09:27:21 +0800
Subject: [PATCH] Match Finder table behavior and improve performance
---
.../workflows/cleanzip-liquid-glass-icon.yml | 7 +-
README.md | 16 +-
work/CleanZipBuild/src/CleanZip-Info.plist | 2 +-
.../src/CleanZipService-Info.plist | 2 +-
work/CleanZipBuild/src/build.sh | 2 +
work/CleanZipBuild/src/main.swift | 311 ++++++++++++------
.../tests/TableBehaviorTests.swift | 272 +++++++++++++++
work/CleanZipBuild/tests/run.sh | 21 ++
8 files changed, 530 insertions(+), 103 deletions(-)
create mode 100644 work/CleanZipBuild/tests/TableBehaviorTests.swift
create mode 100755 work/CleanZipBuild/tests/run.sh
diff --git a/.github/workflows/cleanzip-liquid-glass-icon.yml b/.github/workflows/cleanzip-liquid-glass-icon.yml
index 6ede344..c603d4a 100644
--- a/.github/workflows/cleanzip-liquid-glass-icon.yml
+++ b/.github/workflows/cleanzip-liquid-glass-icon.yml
@@ -6,7 +6,7 @@ on:
release_tag:
description: "Release tag to update"
required: true
- default: "v2.6.31"
+ default: "v2.6.32"
upload_release:
description: "Upload pkg, zip, and checksums to the release"
required: true
@@ -31,6 +31,7 @@ on:
- "work/CleanZipBuild/src/generate_xcode_project.rb"
- "work/CleanZipBuild/src/install_icon_assets.sh"
- "work/CleanZipBuild/assets/CleanZipIcon.original.icns"
+ - "work/CleanZipBuild/tests/**"
permissions:
contents: write
@@ -67,6 +68,10 @@ jobs:
"$RUNNER_TEMP/cleanzip-venv/bin/python" -m pip install --upgrade pip
"$RUNNER_TEMP/cleanzip-venv/bin/python" -m pip install pillow
+ - name: Run table and archive regression tests
+ shell: bash
+ run: work/CleanZipBuild/tests/run.sh
+
- name: Build app and Finder service from source
shell: bash
run: work/CleanZipBuild/src/build_xcode.sh
diff --git a/README.md b/README.md
index 072da93..8db7260 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
·
Product page
·
- CleanZip 2.6.31
+ CleanZip 2.6.32
·
Build from source
@@ -59,7 +59,7 @@ It is intentionally small: no always-on background app, no history database, no
## Download
-Download `CleanZip-2.6.31.pkg` from the [latest release](https://github.com/lyc280705/CleanZip/releases/latest). For most users, the `.pkg` installer is the easiest option.
+Download `CleanZip-2.6.32.pkg` from the [latest release](https://github.com/lyc280705/CleanZip/releases/latest). For most users, the `.pkg` installer is the easiest option.
The installer places:
@@ -70,9 +70,9 @@ CleanZip is ad-hoc signed for open source distribution, but it is not notarized
For the `.pkg` installer:
-1. In Finder, Control-click `CleanZip-2.6.31.pkg` and choose **Open**.
+1. In Finder, Control-click `CleanZip-2.6.32.pkg` and choose **Open**.
2. If the same warning still appears with only **Done** and **Move to Trash**, open **System Settings** -> **Privacy & Security**.
-3. At the bottom of Privacy & Security, choose **Open Anyway** for `CleanZip-2.6.31.pkg`, then confirm.
+3. At the bottom of Privacy & Security, choose **Open Anyway** for `CleanZip-2.6.32.pkg`, then confirm.
After installation, if macOS blocks `CleanZip.app` itself, Control-click `CleanZip.app` in `/Applications` and choose **Open**. If it is still blocked, use **System Settings** -> **Privacy & Security** -> **Open Anyway** for `CleanZip.app`.
@@ -81,11 +81,11 @@ After you approve the installer or app once, macOS opens it normally.
Advanced terminal alternative for the downloaded package:
```bash
-xattr -dr com.apple.quarantine ~/Downloads/CleanZip-2.6.31.pkg
-open ~/Downloads/CleanZip-2.6.31.pkg
+xattr -dr com.apple.quarantine ~/Downloads/CleanZip-2.6.32.pkg
+open ~/Downloads/CleanZip-2.6.32.pkg
```
-Manual installation is also available from `CleanZip-2.6.31.zip`: move `CleanZip.app` to `/Applications` and `CleanZipService.service` to `/Library/Services`.
+Manual installation is also available from `CleanZip-2.6.32.zip`: move `CleanZip.app` to `/Applications` and `CleanZipService.service` to `/Library/Services`.
## Compatibility
@@ -172,7 +172,7 @@ gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref m
To rebuild and update an existing GitHub release asset set:
```sh
-gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref main -f release_tag=v2.6.31 -f upload_release=true
+gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref main -f release_tag=v2.6.32 -f upload_release=true
```
Lightweight local fallback with Command Line Tools:
diff --git a/work/CleanZipBuild/src/CleanZip-Info.plist b/work/CleanZipBuild/src/CleanZip-Info.plist
index 783b7ec..6ddc2bc 100644
--- a/work/CleanZipBuild/src/CleanZip-Info.plist
+++ b/work/CleanZipBuild/src/CleanZip-Info.plist
@@ -132,7 +132,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
- 31
+ 32
LSApplicationCategoryType
public.app-category.utilities
LSMinimumSystemVersion
diff --git a/work/CleanZipBuild/src/CleanZipService-Info.plist b/work/CleanZipBuild/src/CleanZipService-Info.plist
index 870b5cc..6095686 100644
--- a/work/CleanZipBuild/src/CleanZipService-Info.plist
+++ b/work/CleanZipBuild/src/CleanZipService-Info.plist
@@ -37,7 +37,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
- 31
+ 32
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/work/CleanZipBuild/src/build.sh b/work/CleanZipBuild/src/build.sh
index 98b28bf..2421e7d 100755
--- a/work/CleanZipBuild/src/build.sh
+++ b/work/CleanZipBuild/src/build.sh
@@ -12,6 +12,8 @@ BUILD_DIR="$ROOT/build"
export MACOSX_DEPLOYMENT_TARGET="$DEPLOYMENT_TARGET"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$SERVICE/Contents/MacOS" "$SERVICE/Contents/Resources"
+cp "$ROOT/src/CleanZip-Info.plist" "$APP/Contents/Info.plist"
+cp "$ROOT/src/CleanZipService-Info.plist" "$SERVICE/Contents/Info.plist"
if [[ -d "$RESOURCES" ]]; then
rsync -a "$RESOURCES/" "$APP/Contents/Resources/"
diff --git a/work/CleanZipBuild/src/main.swift b/work/CleanZipBuild/src/main.swift
index 1a78c3d..cdc4e1a 100644
--- a/work/CleanZipBuild/src/main.swift
+++ b/work/CleanZipBuild/src/main.swift
@@ -25,7 +25,7 @@ enum L10n {
}
}
-struct ArchiveEntry: Identifiable {
+struct ArchiveEntry: Identifiable, Equatable {
let id = UUID()
let path: String
let size: Int64
@@ -48,17 +48,29 @@ extension Notification.Name {
static let cleanZipStateDidChange = Notification.Name("local.codex.cleanzip.stateDidChange")
}
-struct SelectedItem: Identifiable {
+struct SelectedItem: Identifiable, Equatable {
let url: URL
+ let isDirectory: Bool
+ let byteSize: Int64?
+
+ init(url: URL) {
+ self.url = url
+ let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
+ isDirectory = values?.isDirectory ?? false
+ if let fileSize = values?.fileSize {
+ byteSize = Int64(fileSize)
+ } else {
+ byteSize = nil
+ }
+ }
+
var id: String { url.path }
var name: String { url.lastPathComponent }
var location: String { url.deletingLastPathComponent().path }
- var isDirectory: Bool { (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false }
var typeName: String { isDirectory ? L10n.tr("item.type.folder") : (url.pathExtension.isEmpty ? L10n.tr("item.type.file") : url.pathExtension.uppercased()) }
var sizeText: String {
if isDirectory { return "--" }
- let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0
- return AppState.formatBytes(size)
+ return AppState.formatBytes(byteSize ?? 0)
}
}
@@ -126,6 +138,12 @@ final class ArchiveEngine {
private let fileManager = FileManager.default
var sevenZipURL: URL? {
+#if CLEANZIP_TESTING
+ if let testPath = ProcessInfo.processInfo.environment["CLEANZIP_7ZZ_PATH"],
+ fileManager.isExecutableFile(atPath: testPath) {
+ return URL(fileURLWithPath: testPath)
+ }
+#endif
if let bundled = Bundle.main.url(forResource: "7zz", withExtension: nil),
fileManager.isExecutableFile(atPath: bundled.path) {
return bundled
@@ -271,9 +289,11 @@ final class ArchiveEngine {
var entries: [ArchiveEntry] = []
var current: [String: String] = [:]
func flush() {
- guard let path = current["Path"], !path.isEmpty, current["Folder"] != nil else { return }
+ guard let path = current["Path"], !path.isEmpty, current["Size"] != nil else { return }
let size = Int64(current["Size"] ?? "0") ?? 0
- entries.append(ArchiveEntry(path: path, size: size, modified: formatArchiveTimestamp(current["Modified"] ?? ""), isDirectory: (current["Folder"] ?? "-") == "+"))
+ let attributes = current["Attributes"] ?? ""
+ let isDirectory = current["Folder"] == "+" || attributes.hasPrefix("D") || path.hasSuffix("/")
+ entries.append(ArchiveEntry(path: path, size: size, modified: formatArchiveTimestamp(current["Modified"] ?? ""), isDirectory: isDirectory))
}
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
let text = String(line)
@@ -347,11 +367,25 @@ final class ArchiveEngine {
@MainActor
final class AppState: ObservableObject {
static let shared = AppState()
- @Published var selectedURLs: [URL] = []
+ @Published var selectedURLs: [URL] = [] {
+ didSet {
+ selectedItems = selectedURLs.map(SelectedItem.init(url:))
+ selectedItemsRevision &+= 1
+ }
+ }
@Published var selectedItemIDs: Set = []
@Published var archiveURL: URL?
- @Published var entries: [ArchiveEntry] = []
- @Published var searchText = ""
+ @Published var entries: [ArchiveEntry] = [] {
+ didSet {
+ entriesRevision &+= 1
+ refreshEntryDerivedState()
+ }
+ }
+ @Published var searchText = "" {
+ didSet {
+ if searchText != oldValue { refreshFilteredEntries() }
+ }
+ }
@Published var status = L10n.tr("status.empty")
@Published var isBusy = false
@Published var operationProgress: OperationProgress?
@@ -359,9 +393,12 @@ final class AppState: ObservableObject {
@Published var format: ArchiveFormat = .zip
@Published var splitPreset: SplitPreset = SplitPreset.all[0]
@Published var customSplitMB = "100"
- var selectedItems: [SelectedItem] { selectedURLs.map(SelectedItem.init(url:)) }
- var totalFiles: Int { entries.filter { !$0.isDirectory }.count }
- var totalBytes: Int64 { entries.reduce(0) { $0 + max($1.size, 0) } }
+ private(set) var selectedItems: [SelectedItem] = []
+ private(set) var selectedItemsRevision = 0
+ private(set) var entriesRevision = 0
+ private(set) var filteredEntries: [ArchiveEntry] = []
+ private(set) var totalFiles = 0
+ private(set) var totalBytes: Int64 = 0
func handle(urls: [URL]) {
guard !urls.isEmpty else { return }
@@ -500,17 +537,20 @@ final class AppState: ObservableObject {
guard let archiveURL else { return }
isBusy = true
status = L10n.tr("status.testing")
+ notifyStateDidChange()
DispatchQueue.global(qos: .userInitiated).async {
do {
try ArchiveEngine.shared.testArchive(archiveURL)
DispatchQueue.main.async {
self.status = L10n.tr("status.testPassed")
self.isBusy = false
+ self.notifyStateDidChange()
}
} catch {
DispatchQueue.main.async {
self.status = L10n.tr("status.testFailed", error.localizedDescription)
self.isBusy = false
+ self.notifyStateDidChange()
}
}
}
@@ -569,6 +609,26 @@ final class AppState: ObservableObject {
return result
}
+ private func refreshEntryDerivedState() {
+ var files = 0
+ var bytes: Int64 = 0
+ for entry in entries where !entry.isDirectory {
+ files += 1
+ bytes += max(entry.size, 0)
+ }
+ totalFiles = files
+ totalBytes = bytes
+ refreshFilteredEntries()
+ }
+
+ private func refreshFilteredEntries() {
+ if searchText.isEmpty {
+ filteredEntries = entries
+ } else {
+ filteredEntries = entries.filter { $0.path.localizedCaseInsensitiveContains(searchText) }
+ }
+ }
+
func prepareArchiveOperation(urls: [URL], title: String, detail: String) {
searchText = ""
archiveURL = urls.count == 1 ? urls[0] : nil
@@ -588,16 +648,16 @@ final class AppState: ObservableObject {
func updateOperationProgress(_ fraction: Double) {
let clamped = min(max(fraction, 0), 1)
let previous = operationProgress?.fraction ?? -1
- guard clamped >= 1 || abs(clamped - previous) >= 0.005 else { return }
+ let percent = Int((clamped * 100).rounded())
+ let previousPercent = Int((previous * 100).rounded())
+ guard clamped >= 1 || percent != previousPercent else { return }
if operationProgress == nil {
operationProgress = OperationProgress(title: L10n.tr("operation.processing"), detail: "", fraction: clamped)
} else {
operationProgress?.fraction = clamped
}
let title = operationProgress?.title ?? L10n.tr("operation.processing")
- let percent = Int((clamped * 100).rounded())
status = L10n.tr("status.progress", title, String(percent))
- notifyStateDidChange()
}
func finishOperation(status: String) {
@@ -612,6 +672,37 @@ final class AppState: ObservableObject {
}
}
+enum FinderTableBehavior {
+ static func configure(_ table: NSTableView, allowsMultipleSelection: Bool, autosaveName: String) {
+ table.usesAlternatingRowBackgroundColors = true
+ table.allowsColumnReordering = true
+ table.allowsColumnResizing = true
+ table.allowsColumnSelection = false
+ table.allowsMultipleSelection = allowsMultipleSelection
+ table.rowHeight = 26
+ table.headerView = NSTableHeaderView()
+ table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
+ table.autosaveName = autosaveName
+ table.autosaveTableColumns = true
+ table.backgroundColor = .clear
+ }
+
+ static func shouldReorderColumn(
+ in tableView: NSTableView,
+ columnIndex: Int,
+ newColumnIndex: Int,
+ lockedIdentifier: String
+ ) -> Bool {
+ guard tableView.tableColumns.indices.contains(columnIndex) else { return false }
+ let column = tableView.tableColumns[columnIndex]
+ guard column.identifier.rawValue != lockedIdentifier else { return false }
+
+ // AppKit first proposes -1 when a header drag begins. Other columns may
+ // start dragging, but index 0 remains reserved for the name column.
+ return newColumnIndex == -1 || newColumnIndex > 0
+ }
+}
+
final class FinderTableScrollView: NSScrollView {
private var isRebalancingColumns = false
private var lastViewportWidth: CGFloat = 0
@@ -629,10 +720,13 @@ final class FinderTableScrollView: NSScrollView {
table.numberOfColumns > 0 else { return }
let viewportWidth = floor(contentView.bounds.width)
+ guard viewportWidth > 0 else { return }
let columnsWidth = ceil(table.rect(ofColumn: table.numberOfColumns - 1).maxX)
- let gap = viewportWidth - columnsWidth
let isInitialLayout = lastViewportWidth <= 0
let viewportChanged = !isInitialLayout && abs(viewportWidth - lastViewportWidth) > 0.5
+ guard isInitialLayout || viewportChanged || resizedColumn != nil else { return }
+
+ let gap = viewportWidth - columnsWidth
let nonFirstColumnChanged = resizedColumn != nil && resizedColumn !== firstColumn
let shouldRebalance = isInitialLayout || gap > 0.5 || (columnsWereFitted && (viewportChanged || nonFirstColumnChanged))
@@ -645,6 +739,16 @@ final class FinderTableScrollView: NSScrollView {
let fittedWidth = ceil(table.rect(ofColumn: table.numberOfColumns - 1).maxX)
columnsWereFitted = abs(viewportWidth - fittedWidth) <= 1
lastViewportWidth = viewportWidth
+
+ let documentWidth = max(viewportWidth, fittedWidth)
+ if abs(table.frame.width - documentWidth) > 0.5 {
+ isRebalancingColumns = true
+ let autoresizingStyle = table.columnAutoresizingStyle
+ table.columnAutoresizingStyle = .noColumnAutoresizing
+ table.setFrameSize(NSSize(width: documentWidth, height: table.frame.height))
+ table.columnAutoresizingStyle = autoresizingStyle
+ isRebalancingColumns = false
+ }
}
}
@@ -691,7 +795,12 @@ final class ServiceProgressHUD {
func update(fraction newFraction: Double, detail newDetail: String? = nil) {
guard !finished else { return }
- fraction = min(max(newFraction, 0), 1)
+ let clamped = min(max(newFraction, 0), 1)
+ let percentChanged = Int((clamped * 100).rounded()) != Int((fraction * 100).rounded())
+ let detailChanged = newDetail.map { !$0.isEmpty && $0 != detail } ?? false
+ guard percentChanged || detailChanged else { return }
+
+ fraction = clamped
if let newDetail, !newDetail.isEmpty { detail = newDetail }
updateVisibleControls()
}
@@ -886,11 +995,19 @@ final class ServiceProgressHUD {
struct ArchiveEntriesTable: NSViewRepresentable {
let entries: [ArchiveEntry]
+ let contentRevision: Int
+ let filter: String
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var entries: [ArchiveEntry]
- private var isRestoringFirstColumn = false
- init(entries: [ArchiveEntry]) { self.entries = entries }
+ var contentRevision: Int
+ var filter: String
+
+ init(entries: [ArchiveEntry], contentRevision: Int, filter: String) {
+ self.entries = entries
+ self.contentRevision = contentRevision
+ self.filter = filter
+ }
func numberOfRows(in tableView: NSTableView) -> Int { entries.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
@@ -990,40 +1107,32 @@ struct ArchiveEntriesTable: NSViewRepresentable {
func tableViewColumnDidMove(_ notification: Notification) {
guard let tableView = notification.object as? NSTableView else { return }
- restoreFirstColumn(in: tableView, identifier: "name")
(tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
}
func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool {
- guard tableView.tableColumns.indices.contains(columnIndex) else { return false }
- return tableView.tableColumns[columnIndex].identifier.rawValue != "name"
+ FinderTableBehavior.shouldReorderColumn(
+ in: tableView,
+ columnIndex: columnIndex,
+ newColumnIndex: newColumnIndex,
+ lockedIdentifier: "name"
+ )
}
- private func restoreFirstColumn(in tableView: NSTableView, identifier: String) {
- guard !isRestoringFirstColumn else { return }
- let columnIndex = tableView.column(withIdentifier: NSUserInterfaceItemIdentifier(identifier))
- guard columnIndex > 0 else { return }
-
- isRestoringFirstColumn = true
- tableView.moveColumn(columnIndex, toColumn: 0)
- isRestoringFirstColumn = false
- }
+ func tableView(_ tableView: NSTableView, shouldSelect tableColumn: NSTableColumn?) -> Bool { false }
}
- func makeCoordinator() -> Coordinator { Coordinator(entries: entries) }
+ func makeCoordinator() -> Coordinator {
+ Coordinator(entries: entries, contentRevision: contentRevision, filter: filter)
+ }
func makeNSView(context: Context) -> NSScrollView {
let table = NSTableView()
- table.usesAlternatingRowBackgroundColors = true
- table.allowsColumnReordering = true
- table.allowsColumnResizing = true
- table.allowsMultipleSelection = false
- table.rowHeight = 26
- table.headerView = NSTableHeaderView()
- table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
- table.autosaveName = "local.codex.cleanzip.archiveEntriesTable.v6"
- table.autosaveTableColumns = true
- table.backgroundColor = .clear
+ FinderTableBehavior.configure(
+ table,
+ allowsMultipleSelection: false,
+ autosaveName: "local.codex.cleanzip.archiveEntriesTable.v7"
+ )
let columns: [(String, String, CGFloat, CGFloat, CGFloat)] = [
("name", L10n.tr("column.name"), 380, 180, .greatestFiniteMagnitude),
("size", L10n.tr("column.size"), 140, 96, 280),
@@ -1051,8 +1160,11 @@ struct ArchiveEntriesTable: NSViewRepresentable {
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
- context.coordinator.entries = entries
- if let table = scrollView.documentView as? NSTableView {
+ let contentChanged = context.coordinator.contentRevision != contentRevision || context.coordinator.filter != filter
+ if contentChanged, let table = scrollView.documentView as? NSTableView {
+ context.coordinator.entries = entries
+ context.coordinator.contentRevision = contentRevision
+ context.coordinator.filter = filter
table.reloadData()
}
(scrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
@@ -1061,17 +1173,21 @@ struct ArchiveEntriesTable: NSViewRepresentable {
struct SelectedItemsTable: NSViewRepresentable {
let items: [SelectedItem]
+ let contentRevision: Int
@Binding var selectedIDs: Set
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var items: [SelectedItem]
+ var contentRevision: Int
var selectedIDs: Binding>
+ var appliedSelection: Set
private var isApplyingSelection = false
- private var isRestoringFirstColumn = false
- init(items: [SelectedItem], selectedIDs: Binding>) {
+ init(items: [SelectedItem], contentRevision: Int, selectedIDs: Binding>) {
self.items = items
+ self.contentRevision = contentRevision
self.selectedIDs = selectedIDs
+ appliedSelection = selectedIDs.wrappedValue
}
func numberOfRows(in tableView: NSTableView) -> Int { items.count }
@@ -1099,7 +1215,11 @@ struct SelectedItemsTable: NSViewRepresentable {
let ids = tableView.selectedRowIndexes.compactMap { row in
row < items.count ? items[row].id : nil
}
- selectedIDs.wrappedValue = Set(ids)
+ let selection = Set(ids)
+ appliedSelection = selection
+ if selectedIDs.wrappedValue != selection {
+ selectedIDs.wrappedValue = selection
+ }
}
func tableViewColumnDidResize(_ notification: Notification) {
@@ -1110,24 +1230,19 @@ struct SelectedItemsTable: NSViewRepresentable {
func tableViewColumnDidMove(_ notification: Notification) {
guard let tableView = notification.object as? NSTableView else { return }
- restoreFirstColumn(in: tableView, identifier: "selectedName")
(tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
}
func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool {
- guard tableView.tableColumns.indices.contains(columnIndex) else { return false }
- return tableView.tableColumns[columnIndex].identifier.rawValue != "selectedName"
+ FinderTableBehavior.shouldReorderColumn(
+ in: tableView,
+ columnIndex: columnIndex,
+ newColumnIndex: newColumnIndex,
+ lockedIdentifier: "selectedName"
+ )
}
- private func restoreFirstColumn(in tableView: NSTableView, identifier: String) {
- guard !isRestoringFirstColumn else { return }
- let columnIndex = tableView.column(withIdentifier: NSUserInterfaceItemIdentifier(identifier))
- guard columnIndex > 0 else { return }
-
- isRestoringFirstColumn = true
- tableView.moveColumn(columnIndex, toColumn: 0)
- isRestoringFirstColumn = false
- }
+ func tableView(_ tableView: NSTableView, shouldSelect tableColumn: NSTableColumn?) -> Bool { false }
func applySelection(to tableView: NSTableView) {
isApplyingSelection = true
@@ -1136,6 +1251,7 @@ struct SelectedItemsTable: NSViewRepresentable {
selectedIDs.wrappedValue.contains(item.id) ? index : nil
})
tableView.selectRowIndexes(indexes, byExtendingSelection: false)
+ appliedSelection = selectedIDs.wrappedValue
}
private func nameCell(tableView: NSTableView, item: SelectedItem) -> NSView {
@@ -1211,21 +1327,16 @@ struct SelectedItemsTable: NSViewRepresentable {
}
func makeCoordinator() -> Coordinator {
- Coordinator(items: items, selectedIDs: $selectedIDs)
+ Coordinator(items: items, contentRevision: contentRevision, selectedIDs: $selectedIDs)
}
func makeNSView(context: Context) -> NSScrollView {
let table = NSTableView()
- table.usesAlternatingRowBackgroundColors = true
- table.allowsColumnReordering = true
- table.allowsColumnResizing = true
- table.allowsMultipleSelection = true
- table.rowHeight = 26
- table.headerView = NSTableHeaderView()
- table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
- table.autosaveName = "local.codex.cleanzip.selectedItemsTable.v6"
- table.autosaveTableColumns = true
- table.backgroundColor = .clear
+ FinderTableBehavior.configure(
+ table,
+ allowsMultipleSelection: true,
+ autosaveName: "local.codex.cleanzip.selectedItemsTable.v7"
+ )
let columns: [(String, String, CGFloat, CGFloat, CGFloat)] = [
("selectedName", L10n.tr("column.name"), 260, 180, .greatestFiniteMagnitude),
@@ -1257,11 +1368,18 @@ struct SelectedItemsTable: NSViewRepresentable {
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
- context.coordinator.items = items
+ let contentChanged = context.coordinator.contentRevision != contentRevision
+ let selectionChanged = context.coordinator.appliedSelection != selectedIDs
context.coordinator.selectedIDs = $selectedIDs
if let table = scrollView.documentView as? NSTableView {
- table.reloadData()
- context.coordinator.applySelection(to: table)
+ if contentChanged {
+ context.coordinator.items = items
+ context.coordinator.contentRevision = contentRevision
+ table.reloadData()
+ }
+ if contentChanged || selectionChanged {
+ context.coordinator.applySelection(to: table)
+ }
}
(scrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
}
@@ -1272,11 +1390,6 @@ struct ContentView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var dropIsTargeted = false
- private var filteredEntries: [ArchiveEntry] {
- guard !state.searchText.isEmpty else { return state.entries }
- return state.entries.filter { $0.path.localizedCaseInsensitiveContains(state.searchText) }
- }
-
var body: some View {
VStack(spacing: 0) {
ZStack {
@@ -1299,7 +1412,7 @@ struct ContentView: View {
.frame(minWidth: 820, minHeight: 560)
.background(SystemContentBackground().ignoresSafeArea())
.animation(contentAnimation, value: contentIdentity)
- .animation(contentAnimation, value: dropIsTargeted)
+ .animation(dropAnimation, value: dropIsTargeted)
.onDrop(of: [.fileURL], isTargeted: $dropIsTargeted, perform: handleDrop)
.sheet(isPresented: $state.showingCompressSheet) { CompressSheet().environmentObject(state) }
}
@@ -1311,14 +1424,15 @@ struct ContentView: View {
}
private var contentAnimation: Animation? {
- reduceMotion ? nil : .easeInOut(duration: 0.18)
+ reduceMotion ? nil : .smooth(duration: 0.16)
+ }
+
+ private var dropAnimation: Animation? {
+ reduceMotion ? nil : .snappy(duration: 0.18, extraBounce: 0)
}
private var contentTransition: AnyTransition {
- reduceMotion ? .opacity : .asymmetric(
- insertion: .opacity.combined(with: .move(edge: .bottom)),
- removal: .opacity
- )
+ .opacity
}
@ViewBuilder
@@ -1358,7 +1472,11 @@ struct ContentView: View {
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
- ArchiveEntriesTable(entries: filteredEntries)
+ ArchiveEntriesTable(
+ entries: state.filteredEntries,
+ contentRevision: state.entriesRevision,
+ filter: state.searchText
+ )
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
@@ -1375,7 +1493,11 @@ struct ContentView: View {
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
- SelectedItemsTable(items: state.selectedItems, selectedIDs: $state.selectedItemIDs)
+ SelectedItemsTable(
+ items: state.selectedItems,
+ contentRevision: state.selectedItemsRevision,
+ selectedIDs: $state.selectedItemIDs
+ )
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
@@ -1406,17 +1528,20 @@ struct ContentView: View {
}
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
- var urls: [URL] = []
+ let lock = NSLock()
+ var urls = [URL?](repeating: nil, count: providers.count)
let group = DispatchGroup()
- for provider in providers {
+ for (index, provider) in providers.enumerated() {
group.enter()
provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
defer { group.leave() }
guard let data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
- urls.append(url)
+ lock.lock()
+ urls[index] = url
+ lock.unlock()
}
}
- group.notify(queue: .main) { state.handle(urls: urls) }
+ group.notify(queue: .main) { state.handle(urls: urls.compactMap { $0 }) }
return true
}
}
@@ -1446,7 +1571,7 @@ struct CompressSheet: View {
}
}
.formStyle(.grouped)
- .animation(reduceMotion ? nil : .easeInOut(duration: 0.16), value: state.splitPreset.id)
+ .animation(reduceMotion ? nil : .smooth(duration: 0.16), value: state.splitPreset.id)
Text(L10n.tr("settings.cleanMetadataNote")).foregroundStyle(.secondary).font(.footnote)
HStack {
Spacer()
@@ -1887,6 +2012,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
}
}
+#if !CLEANZIP_TESTING
@main
@MainActor
struct CleanZipMain {
@@ -1898,3 +2024,4 @@ struct CleanZipMain {
app.run()
}
}
+#endif
diff --git a/work/CleanZipBuild/tests/TableBehaviorTests.swift b/work/CleanZipBuild/tests/TableBehaviorTests.swift
new file mode 100644
index 0000000..5d3595e
--- /dev/null
+++ b/work/CleanZipBuild/tests/TableBehaviorTests.swift
@@ -0,0 +1,272 @@
+import AppKit
+import SwiftUI
+
+@main
+@MainActor
+struct TableBehaviorTests {
+ private static var failures: [String] = []
+
+ static func main() {
+ _ = NSApplication.shared
+ testNativeTableConfiguration()
+ testArchiveColumnReorderingPolicy()
+ testSelectedItemsColumnReorderingPolicy()
+ testFinderStyleColumnSizing()
+ testSelectedItemMetadataSnapshot()
+ testProgressPublishingIsDeduplicated()
+ testArchiveDerivedStateIsCached()
+ testArchiveEngineRoundTrips()
+
+ if failures.isEmpty {
+ print("PASS: CleanZip table behavior tests")
+ exit(EXIT_SUCCESS)
+ }
+
+ failures.forEach { fputs("FAIL: \($0)\n", stderr) }
+ exit(EXIT_FAILURE)
+ }
+
+ private static func testNativeTableConfiguration() {
+ let table = NSTableView()
+ FinderTableBehavior.configure(
+ table,
+ allowsMultipleSelection: true,
+ autosaveName: "local.codex.cleanzip.tests.\(UUID().uuidString)"
+ )
+
+ expect(table.allowsColumnReordering, "column reordering should be enabled")
+ expect(table.allowsColumnResizing, "column resizing should be enabled")
+ expect(!table.allowsColumnSelection, "column headers must never become selected")
+ expect(table.allowsMultipleSelection, "selected-items rows should allow multiple selection")
+ expect(table.columnAutoresizingStyle == .firstColumnOnlyAutoresizingStyle, "only the first column should autoresize with the window")
+ }
+
+ private static func testArchiveColumnReorderingPolicy() {
+ let table = makeTable(identifiers: ["name", "size", "modified"])
+ let coordinator = ArchiveEntriesTable.Coordinator(entries: [], contentRevision: 0, filter: "")
+
+ expect(!coordinator.tableView(table, shouldSelect: table.tableColumns[0]), "archive headers should not be selectable")
+ expect(!coordinator.tableView(table, shouldReorderColumn: 0, toColumn: -1), "archive name column must reject drag initiation")
+ expect(!coordinator.tableView(table, shouldReorderColumn: 0, toColumn: 2), "archive name column must stay locked")
+ expect(coordinator.tableView(table, shouldReorderColumn: 1, toColumn: -1), "archive size column should start dragging")
+ expect(!coordinator.tableView(table, shouldReorderColumn: 1, toColumn: 0), "archive columns must not displace the name column")
+ expect(coordinator.tableView(table, shouldReorderColumn: 1, toColumn: 2), "archive non-name columns should reorder")
+ }
+
+ private static func testSelectedItemsColumnReorderingPolicy() {
+ let table = makeTable(identifiers: ["selectedName", "selectedType", "selectedSize", "selectedLocation"])
+ let coordinator = SelectedItemsTable.Coordinator(
+ items: [],
+ contentRevision: 0,
+ selectedIDs: .constant([])
+ )
+
+ expect(!coordinator.tableView(table, shouldSelect: table.tableColumns[0]), "selected-item headers should not be selectable")
+ expect(!coordinator.tableView(table, shouldReorderColumn: 0, toColumn: -1), "selected-item name column must reject drag initiation")
+ expect(coordinator.tableView(table, shouldReorderColumn: 1, toColumn: -1), "selected-item type column should start dragging")
+ expect(!coordinator.tableView(table, shouldReorderColumn: 3, toColumn: 0), "selected-item columns must not move before name")
+ expect(coordinator.tableView(table, shouldReorderColumn: 3, toColumn: 1), "selected-item non-name columns should reorder")
+ }
+
+ private static func testFinderStyleColumnSizing() {
+ let scroll = FinderTableScrollView(frame: NSRect(x: 0, y: 0, width: 820, height: 400))
+ scroll.hasVerticalScroller = true
+ scroll.hasHorizontalScroller = true
+ scroll.autohidesScrollers = true
+
+ let table = makeTable(identifiers: ["name", "type", "size", "location"])
+ let widths: [(CGFloat, CGFloat, CGFloat)] = [
+ (280, 180, .greatestFiniteMagnitude),
+ (90, 70, 160),
+ (120, 90, 220),
+ (260, 220, 2_000)
+ ]
+ for (column, sizing) in zip(table.tableColumns, widths) {
+ column.width = sizing.0
+ column.minWidth = sizing.1
+ column.maxWidth = sizing.2
+ column.resizingMask = [.userResizingMask, .autoresizingMask]
+ }
+ table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
+ scroll.documentView = table
+ scroll.layoutSubtreeIfNeeded()
+ scroll.rebalanceColumnsToViewport()
+
+ let fixedWidths = Array(table.tableColumns.dropFirst().map(\.width))
+ expect(isFitted(table, in: scroll), "initial columns should snap to the viewport")
+ expect(!canScrollHorizontally(scroll), "a fitted table must not scroll horizontally")
+
+ let firstBeforeGrow = table.tableColumns[0].width
+ scroll.setFrameSize(NSSize(width: 980, height: 400))
+ scroll.layoutSubtreeIfNeeded()
+ scroll.rebalanceColumnsToViewport()
+ expect(table.tableColumns[0].width > firstBeforeGrow, "growing the window should grow the first column")
+ expect(equalWidths(Array(table.tableColumns.dropFirst().map(\.width)), fixedWidths), "growing the window must not resize later columns")
+ expect(isFitted(table, in: scroll), "grown columns should remain fitted")
+ expect(!canScrollHorizontally(scroll), "a grown fitted table must not scroll horizontally")
+
+ let firstBeforeShrink = table.tableColumns[0].width
+ scroll.setFrameSize(NSSize(width: 860, height: 400))
+ scroll.layoutSubtreeIfNeeded()
+ scroll.rebalanceColumnsToViewport()
+ expect(table.tableColumns[0].width < firstBeforeShrink, "shrinking the window should shrink the first column")
+ expect(equalWidths(Array(table.tableColumns.dropFirst().map(\.width)), fixedWidths), "shrinking the window must not resize later columns")
+ expect(isFitted(table, in: scroll), "shrunk columns should remain fitted while the first column has room")
+ expect(!canScrollHorizontally(scroll), "a shrunk fitted table must not scroll horizontally")
+
+ let typeColumn = table.tableColumns[1]
+ let firstBeforeDividerDrag = table.tableColumns[0].width
+ typeColumn.width += 36
+ scroll.rebalanceColumnsToViewport(resizedColumn: typeColumn)
+ expect(approximately(table.tableColumns[0].width, firstBeforeDividerDrag - 36), "resizing a later divider should absorb the delta in the first column")
+ expect(isFitted(table, in: scroll), "later-column resizing should snap back to the viewport")
+
+ table.tableColumns[0].width = table.tableColumns[0].minWidth
+ scroll.setFrameSize(NSSize(width: 420, height: 400))
+ scroll.layoutSubtreeIfNeeded()
+ scroll.rebalanceColumnsToViewport(resizedColumn: table.tableColumns[0])
+ expect(approximately(table.tableColumns[0].width, table.tableColumns[0].minWidth), "the first column must respect its minimum width")
+ expect(columnsWidth(table) > scroll.contentView.bounds.width + 1, "the table should overflow only after the first column reaches its minimum")
+ expect(canScrollHorizontally(scroll), "horizontal scrolling should become available after minimum-width overflow")
+ }
+
+ private static func testSelectedItemMetadataSnapshot() {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cleanzip-table-tests-\(UUID().uuidString)")
+ let file = directory.appendingPathComponent("sample.txt")
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try? Data("hello".utf8).write(to: file)
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let item = SelectedItem(url: file)
+ expect(!item.isDirectory, "file metadata should identify a regular file")
+ expect(item.byteSize == 5, "file metadata should be captured once with the correct size")
+ }
+
+ private static func testProgressPublishingIsDeduplicated() {
+ let state = AppState()
+ state.beginOperation(title: "Test", detail: "")
+ state.updateOperationProgress(0.001)
+ expect(state.operationProgress?.fraction == 0, "sub-percent progress should not trigger a redundant UI update")
+ state.updateOperationProgress(0.006)
+ expect(state.operationProgress?.fraction == 0.006, "a new displayed percent should update progress")
+ state.updateOperationProgress(0.009)
+ expect(state.operationProgress?.fraction == 0.006, "progress within the same displayed percent should be deduplicated")
+ }
+
+ private static func testArchiveDerivedStateIsCached() {
+ let state = AppState()
+ state.entries = [
+ ArchiveEntry(path: "Folder", size: 0, modified: "", isDirectory: true),
+ ArchiveEntry(path: "Folder/keep.txt", size: 5, modified: "", isDirectory: false),
+ ArchiveEntry(path: "Folder/other.txt", size: 7, modified: "", isDirectory: false)
+ ]
+ expect(state.totalFiles == 2, "archive file count should be cached when entries change")
+ expect(state.totalBytes == 12, "archive byte count should be cached when entries change")
+ expect(state.filteredEntries.count == 3, "empty search should reuse all archive entries")
+
+ state.searchText = "keep"
+ expect(state.filteredEntries.map(\.path) == ["Folder/keep.txt"], "search results should refresh only when the query changes")
+ state.beginOperation(title: "Test", detail: "")
+ state.updateOperationProgress(0.5)
+ expect(state.filteredEntries.map(\.path) == ["Folder/keep.txt"], "progress updates must not rebuild archive search results")
+ }
+
+ private static func testArchiveEngineRoundTrips() {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("cleanzip-engine-tests-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ do {
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ try testArchiveFormat(.zip, in: root.appendingPathComponent("zip"))
+ try testArchiveFormat(.sevenZ, in: root.appendingPathComponent("7z"))
+ try testSplitArchiveFormat(.zip, in: root.appendingPathComponent("split-zip"))
+ try testSplitArchiveFormat(.sevenZ, in: root.appendingPathComponent("split-7z"))
+ } catch {
+ failures.append("archive round-trip failed: \(error.localizedDescription)")
+ }
+ }
+
+ private static func testArchiveFormat(_ format: ArchiveFormat, in directory: URL) throws {
+ let payload = try makePayload(in: directory)
+ let archive = try ArchiveEngine.shared.compress(urls: [payload], format: format, splitSpec: nil)
+ try ArchiveEngine.shared.testArchive(archive)
+
+ let entries = try ArchiveEngine.shared.listArchive(archive)
+ expect(entries.contains { $0.path.hasSuffix("keep.txt") }, "\(format.rawValue) should contain ordinary files")
+ expect(entries.contains { $0.path.hasSuffix("中文文件.txt") }, "\(format.rawValue) should preserve Unicode names")
+ expect(entries.allSatisfy { isCleanArchivePath($0.path) }, "\(format.rawValue) should exclude macOS metadata")
+
+ let extracted = try ArchiveEngine.shared.extract(archive: archive)
+ let extractedFile = extracted.appendingPathComponent(payload.lastPathComponent).appendingPathComponent("keep.txt")
+ expect(FileManager.default.fileExists(atPath: extractedFile.path), "\(format.rawValue) should extract ordinary files")
+ }
+
+ private static func testSplitArchiveFormat(_ format: ArchiveFormat, in directory: URL) throws {
+ let payload = try makePayload(in: directory)
+ let archive = try ArchiveEngine.shared.compress(urls: [payload], format: format, splitSpec: "1k")
+ let firstVolume = URL(fileURLWithPath: archive.path + ".001")
+ expect(FileManager.default.fileExists(atPath: firstVolume.path), "split \(format.rawValue) should create a .001 volume")
+ try ArchiveEngine.shared.testArchive(firstVolume)
+ let entries = try ArchiveEngine.shared.listArchive(firstVolume)
+ expect(entries.allSatisfy { isCleanArchivePath($0.path) }, "split \(format.rawValue) should exclude macOS metadata")
+ }
+
+ private static func makePayload(in directory: URL) throws -> URL {
+ let payload = directory.appendingPathComponent("Payload")
+ try FileManager.default.createDirectory(at: payload.appendingPathComponent("__MACOSX"), withIntermediateDirectories: true)
+ try Data("keep".utf8).write(to: payload.appendingPathComponent("keep.txt"))
+ try Data("unicode".utf8).write(to: payload.appendingPathComponent("中文文件.txt"))
+ try Data("metadata".utf8).write(to: payload.appendingPathComponent(".DS_Store"))
+ try Data("metadata".utf8).write(to: payload.appendingPathComponent("._keep.txt"))
+ try Data("metadata".utf8).write(to: payload.appendingPathComponent("__MACOSX/metadata"))
+
+ var bytes = [UInt8](repeating: 0, count: 8_192)
+ for index in bytes.indices { bytes[index] = UInt8((index * 31 + 17) % 251) }
+ try Data(bytes).write(to: payload.appendingPathComponent("data.bin"))
+ return payload
+ }
+
+ private static func isCleanArchivePath(_ path: String) -> Bool {
+ let components = path.split(separator: "/").map(String.init)
+ return !components.contains(".DS_Store") &&
+ !components.contains("__MACOSX") &&
+ !components.contains { $0.hasPrefix("._") }
+ }
+
+ private static func makeTable(identifiers: [String]) -> NSTableView {
+ let table = NSTableView()
+ for identifier in identifiers {
+ table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier(identifier)))
+ }
+ return table
+ }
+
+ private static func columnsWidth(_ table: NSTableView) -> CGFloat {
+ guard table.numberOfColumns > 0 else { return 0 }
+ return table.rect(ofColumn: table.numberOfColumns - 1).maxX
+ }
+
+ private static func isFitted(_ table: NSTableView, in scroll: NSScrollView) -> Bool {
+ approximately(columnsWidth(table), scroll.contentView.bounds.width, tolerance: 1.5)
+ }
+
+ private static func canScrollHorizontally(_ scroll: NSScrollView) -> Bool {
+ let clipView = scroll.contentView
+ var proposedBounds = clipView.bounds
+ proposedBounds.origin.x += 80
+ let constrainedBounds = clipView.constrainBoundsRect(proposedBounds)
+ return abs(constrainedBounds.origin.x - clipView.bounds.origin.x) > 1
+ }
+
+ private static func equalWidths(_ lhs: [CGFloat], _ rhs: [CGFloat]) -> Bool {
+ lhs.count == rhs.count && zip(lhs, rhs).allSatisfy { approximately($0, $1) }
+ }
+
+ private static func approximately(_ lhs: CGFloat, _ rhs: CGFloat, tolerance: CGFloat = 1) -> Bool {
+ abs(lhs - rhs) <= tolerance
+ }
+
+ private static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
+ if !condition() { failures.append(message) }
+ }
+}
diff --git a/work/CleanZipBuild/tests/run.sh b/work/CleanZipBuild/tests/run.sh
new file mode 100755
index 0000000..c66404f
--- /dev/null
+++ b/work/CleanZipBuild/tests/run.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cleanzip-tests.XXXXXX")"
+trap 'rm -rf "$BUILD_DIR"' EXIT
+
+target="$(uname -m)-apple-macos14.0"
+
+xcrun swiftc -Onone -parse-as-library -DCLEANZIP_TESTING \
+ -target "$target" \
+ -framework AppKit \
+ -framework SwiftUI \
+ -framework Combine \
+ -framework UniformTypeIdentifiers \
+ -framework UserNotifications \
+ "$ROOT/src/main.swift" \
+ "$ROOT/tests/TableBehaviorTests.swift" \
+ -o "$BUILD_DIR/TableBehaviorTests"
+
+CLEANZIP_7ZZ_PATH="$ROOT/src/Resources/7zz" "$BUILD_DIR/TableBehaviorTests"