From b493675b773cd921c62acd18359981a673bd5c91 Mon Sep 17 00:00:00 2001
From: Yuchen Lin <381564291@qq.com>
Date: Sat, 11 Jul 2026 15:37:19 +0800
Subject: [PATCH] Use native AppKit table behavior
---
.../workflows/cleanzip-liquid-glass-icon.yml | 2 +-
README.md | 16 +--
work/CleanZipBuild/src/CleanZip-Info.plist | 2 +-
.../src/CleanZipService-Info.plist | 2 +-
work/CleanZipBuild/src/main.swift | 101 +++---------------
.../tests/TableBehaviorTests.swift | 75 +++++--------
6 files changed, 56 insertions(+), 142 deletions(-)
diff --git a/.github/workflows/cleanzip-liquid-glass-icon.yml b/.github/workflows/cleanzip-liquid-glass-icon.yml
index c603d4a..85c3321 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.32"
+ default: "v2.6.33"
upload_release:
description: "Upload pkg, zip, and checksums to the release"
required: true
diff --git a/README.md b/README.md
index 8db7260..db32c67 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
·
Product page
·
- CleanZip 2.6.32
+ CleanZip 2.6.33
·
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.32.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.33.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.32.pkg` and choose **Open**.
+1. In Finder, Control-click `CleanZip-2.6.33.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.32.pkg`, then confirm.
+3. At the bottom of Privacy & Security, choose **Open Anyway** for `CleanZip-2.6.33.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.32.pkg
-open ~/Downloads/CleanZip-2.6.32.pkg
+xattr -dr com.apple.quarantine ~/Downloads/CleanZip-2.6.33.pkg
+open ~/Downloads/CleanZip-2.6.33.pkg
```
-Manual installation is also available from `CleanZip-2.6.32.zip`: move `CleanZip.app` to `/Applications` and `CleanZipService.service` to `/Library/Services`.
+Manual installation is also available from `CleanZip-2.6.33.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.32 -f upload_release=true
+gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref main -f release_tag=v2.6.33 -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 6ddc2bc..5971867 100644
--- a/work/CleanZipBuild/src/CleanZip-Info.plist
+++ b/work/CleanZipBuild/src/CleanZip-Info.plist
@@ -132,7 +132,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
- 32
+ 33
LSApplicationCategoryType
public.app-category.utilities
LSMinimumSystemVersion
diff --git a/work/CleanZipBuild/src/CleanZipService-Info.plist b/work/CleanZipBuild/src/CleanZipService-Info.plist
index 6095686..04d5340 100644
--- a/work/CleanZipBuild/src/CleanZipService-Info.plist
+++ b/work/CleanZipBuild/src/CleanZipService-Info.plist
@@ -37,7 +37,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
- 32
+ 33
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/work/CleanZipBuild/src/main.swift b/work/CleanZipBuild/src/main.swift
index cdc4e1a..9d00ff1 100644
--- a/work/CleanZipBuild/src/main.swift
+++ b/work/CleanZipBuild/src/main.swift
@@ -681,12 +681,16 @@ enum FinderTableBehavior {
table.allowsMultipleSelection = allowsMultipleSelection
table.rowHeight = 26
table.headerView = NSTableHeaderView()
- table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
+ table.columnAutoresizingStyle = .reverseSequentialColumnAutoresizingStyle
table.autosaveName = autosaveName
table.autosaveTableColumns = true
table.backgroundColor = .clear
}
+ static func resizingMask(isFlexibleColumn: Bool) -> NSTableColumn.ResizingOptions {
+ isFlexibleColumn ? [.userResizingMask, .autoresizingMask] : [.userResizingMask]
+ }
+
static func shouldReorderColumn(
in tableView: NSTableView,
columnIndex: Int,
@@ -703,55 +707,6 @@ enum FinderTableBehavior {
}
}
-final class FinderTableScrollView: NSScrollView {
- private var isRebalancingColumns = false
- private var lastViewportWidth: CGFloat = 0
- private var columnsWereFitted = true
-
- override func layout() {
- super.layout()
- rebalanceColumnsToViewport()
- }
-
- func rebalanceColumnsToViewport(resizedColumn: NSTableColumn? = nil) {
- guard !isRebalancingColumns,
- let table = documentView as? NSTableView,
- let firstColumn = table.tableColumns.first,
- 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 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))
-
- if shouldRebalance && abs(gap) > 0.5 {
- isRebalancingColumns = true
- firstColumn.width = min(firstColumn.maxWidth, max(firstColumn.minWidth, firstColumn.width + gap))
- isRebalancingColumns = false
- }
-
- 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
- }
- }
-}
-
struct SystemContentBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
@@ -1099,17 +1054,6 @@ struct ArchiveEntriesTable: NSViewRepresentable {
return cell
}
- func tableViewColumnDidResize(_ notification: Notification) {
- guard let tableView = notification.object as? NSTableView else { return }
- let resizedColumn = notification.userInfo?["NSTableColumn"] as? NSTableColumn
- (tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport(resizedColumn: resizedColumn)
- }
-
- func tableViewColumnDidMove(_ notification: Notification) {
- guard let tableView = notification.object as? NSTableView else { return }
- (tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
- }
-
func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool {
FinderTableBehavior.shouldReorderColumn(
in: tableView,
@@ -1131,10 +1075,10 @@ struct ArchiveEntriesTable: NSViewRepresentable {
FinderTableBehavior.configure(
table,
allowsMultipleSelection: false,
- autosaveName: "local.codex.cleanzip.archiveEntriesTable.v7"
+ autosaveName: "local.codex.cleanzip.archiveEntriesTable.v13"
)
let columns: [(String, String, CGFloat, CGFloat, CGFloat)] = [
- ("name", L10n.tr("column.name"), 380, 180, .greatestFiniteMagnitude),
+ ("name", L10n.tr("column.name"), 360, 180, .greatestFiniteMagnitude),
("size", L10n.tr("column.size"), 140, 96, 280),
("modified", L10n.tr("column.modified"), 240, 190, 2000)
]
@@ -1144,12 +1088,12 @@ struct ArchiveEntriesTable: NSViewRepresentable {
column.width = spec.2
column.minWidth = spec.3
column.maxWidth = spec.4
- column.resizingMask = [.userResizingMask, .autoresizingMask]
+ column.resizingMask = FinderTableBehavior.resizingMask(isFlexibleColumn: spec.0 == "name")
table.addTableColumn(column)
}
table.delegate = context.coordinator
table.dataSource = context.coordinator
- let scroll = FinderTableScrollView()
+ let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
@@ -1167,7 +1111,6 @@ struct ArchiveEntriesTable: NSViewRepresentable {
context.coordinator.filter = filter
table.reloadData()
}
- (scrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
}
}
@@ -1222,17 +1165,6 @@ struct SelectedItemsTable: NSViewRepresentable {
}
}
- func tableViewColumnDidResize(_ notification: Notification) {
- guard let tableView = notification.object as? NSTableView else { return }
- let resizedColumn = notification.userInfo?["NSTableColumn"] as? NSTableColumn
- (tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport(resizedColumn: resizedColumn)
- }
-
- func tableViewColumnDidMove(_ notification: Notification) {
- guard let tableView = notification.object as? NSTableView else { return }
- (tableView.enclosingScrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
- }
-
func tableView(_ tableView: NSTableView, shouldReorderColumn columnIndex: Int, toColumn newColumnIndex: Int) -> Bool {
FinderTableBehavior.shouldReorderColumn(
in: tableView,
@@ -1335,14 +1267,14 @@ struct SelectedItemsTable: NSViewRepresentable {
FinderTableBehavior.configure(
table,
allowsMultipleSelection: true,
- autosaveName: "local.codex.cleanzip.selectedItemsTable.v7"
+ autosaveName: "local.codex.cleanzip.selectedItemsTable.v13"
)
let columns: [(String, String, CGFloat, CGFloat, CGFloat)] = [
- ("selectedName", L10n.tr("column.name"), 260, 180, .greatestFiniteMagnitude),
- ("selectedType", L10n.tr("column.type"), 90, 70, 160),
- ("selectedSize", L10n.tr("column.size"), 120, 90, 220),
- ("selectedLocation", L10n.tr("column.location"), 360, 220, 2000)
+ ("selectedName", L10n.tr("column.name"), 230, 180, .greatestFiniteMagnitude),
+ ("selectedType", L10n.tr("column.type"), 80, 70, 160),
+ ("selectedSize", L10n.tr("column.size"), 100, 90, 220),
+ ("selectedLocation", L10n.tr("column.location"), 330, 220, 2000)
]
for spec in columns {
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(spec.0))
@@ -1350,13 +1282,13 @@ struct SelectedItemsTable: NSViewRepresentable {
column.width = spec.2
column.minWidth = spec.3
column.maxWidth = spec.4
- column.resizingMask = [.userResizingMask, .autoresizingMask]
+ column.resizingMask = FinderTableBehavior.resizingMask(isFlexibleColumn: spec.0 == "selectedName")
table.addTableColumn(column)
}
table.delegate = context.coordinator
table.dataSource = context.coordinator
- let scroll = FinderTableScrollView()
+ let scroll = NSScrollView()
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
@@ -1381,7 +1313,6 @@ struct SelectedItemsTable: NSViewRepresentable {
context.coordinator.applySelection(to: table)
}
}
- (scrollView as? FinderTableScrollView)?.rebalanceColumnsToViewport()
}
}
diff --git a/work/CleanZipBuild/tests/TableBehaviorTests.swift b/work/CleanZipBuild/tests/TableBehaviorTests.swift
index 5d3595e..ee60c0b 100644
--- a/work/CleanZipBuild/tests/TableBehaviorTests.swift
+++ b/work/CleanZipBuild/tests/TableBehaviorTests.swift
@@ -11,7 +11,7 @@ struct TableBehaviorTests {
testNativeTableConfiguration()
testArchiveColumnReorderingPolicy()
testSelectedItemsColumnReorderingPolicy()
- testFinderStyleColumnSizing()
+ testNativeColumnSizing()
testSelectedItemMetadataSnapshot()
testProgressPublishingIsDeduplicated()
testArchiveDerivedStateIsCached()
@@ -38,7 +38,12 @@ struct TableBehaviorTests {
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")
+ expect(table.columnAutoresizingStyle == .reverseSequentialColumnAutoresizingStyle, "window resizing should use AppKit's reverse sequential policy")
+
+ let flexibleMask = FinderTableBehavior.resizingMask(isFlexibleColumn: true)
+ let fixedMask = FinderTableBehavior.resizingMask(isFlexibleColumn: false)
+ expect(flexibleMask.contains(.userResizingMask) && flexibleMask.contains(.autoresizingMask), "the name column should support user and window resizing")
+ expect(fixedMask.contains(.userResizingMask) && !fixedMask.contains(.autoresizingMask), "later columns should resize only when their divider is dragged")
}
private static func testArchiveColumnReorderingPolicy() {
@@ -68,8 +73,8 @@ struct TableBehaviorTests {
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))
+ private static func testNativeColumnSizing() {
+ let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 820, height: 400))
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.autohidesScrollers = true
@@ -81,53 +86,39 @@ struct TableBehaviorTests {
(120, 90, 220),
(260, 220, 2_000)
]
- for (column, sizing) in zip(table.tableColumns, widths) {
+ for (index, pair) in zip(table.tableColumns.indices, zip(table.tableColumns, widths)) {
+ let (column, sizing) = pair
column.width = sizing.0
column.minWidth = sizing.1
column.maxWidth = sizing.2
- column.resizingMask = [.userResizingMask, .autoresizingMask]
+ column.resizingMask = FinderTableBehavior.resizingMask(isFlexibleColumn: index == 0)
}
- table.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle
+ table.columnAutoresizingStyle = .reverseSequentialColumnAutoresizingStyle
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")
+ expect(!canScrollHorizontally(scroll), "columns that fit should not scroll horizontally")
+ let firstColumn = table.tableColumns[0]
let typeColumn = table.tableColumns[1]
- let firstBeforeDividerDrag = table.tableColumns[0].width
+ let firstBeforeDividerDrag = firstColumn.width
+ let typeBeforeDividerDrag = typeColumn.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")
+ scroll.layoutSubtreeIfNeeded()
+ expect(approximately(firstColumn.width, firstBeforeDividerDrag), "resizing one divider must not rewrite another column")
+ expect(approximately(typeColumn.width, typeBeforeDividerDrag + 36), "the dragged column should keep its native AppKit width")
+ expect(columnsWidth(table) > scroll.contentView.bounds.width, "a wider user-resized column should create native table overflow")
- table.tableColumns[0].width = table.tableColumns[0].minWidth
+ scroll.setFrameSize(NSSize(width: 980, height: 400))
+ scroll.layoutSubtreeIfNeeded()
+ expect(!canScrollHorizontally(scroll), "expanding the viewport should naturally remove overflow")
+
+ firstColumn.width = firstColumn.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")
+ expect(approximately(firstColumn.width, firstColumn.minWidth), "the first column must respect its minimum width")
+ expect(columnsWidth(table) > scroll.contentView.bounds.width + 1, "columns should overflow only when their native minimum widths cannot fit")
+ expect(canScrollHorizontally(scroll), "minimum-width overflow should use the standard horizontal scroller")
}
private static func testSelectedItemMetadataSnapshot() {
@@ -246,10 +237,6 @@ struct TableBehaviorTests {
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
@@ -258,10 +245,6 @@ struct TableBehaviorTests {
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
}