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"