diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6422667
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,28 @@
+name: 2.5 质量检查
+
+on:
+ push:
+ branches: [main, "codex/**"]
+ pull_request:
+
+jobs:
+ macos-tests:
+ runs-on: macos-latest
+ timeout-minutes: 30
+ steps:
+ - name: 检出代码
+ uses: actions/checkout@v4
+
+ - name: 显示 Swift 环境
+ run: swift --version
+
+ - name: 运行标准测试
+ run: ./scripts/run_standard_tests.zsh
+
+ - name: 运行完整兼容性回归
+ run: ./scripts/run_self_tests.zsh
+
+ - name: 严格 Release 构建
+ run: |
+ SDKROOT="$(./scripts/select_macos_sdk.zsh)" swift build -c release \
+ --disable-sandbox -Xswiftc -warnings-as-errors
diff --git a/Assets/Info.plist b/Assets/Info.plist
index b441201..48f5f85 100644
--- a/Assets/Info.plist
+++ b/Assets/Info.plist
@@ -5,13 +5,13 @@
CFBundleDevelopmentRegion
zh_CN
CFBundleDisplayName
- 空间文件夹 2.1.1
+ 空间文件夹
CFBundleExecutable
空间文件夹
CFBundleIconFile
AppIcon
CFBundleIdentifier
- local.spatialfolder.app.v20
+ com.dingjia.spatialfolder
CFBundleInfoDictionaryVersion
6.0
CFBundleName
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.1.1
+ 2.5.0
CFBundleVersion
- 211
+ 250
LSMinimumSystemVersion
14.0
NSHighResolutionCapable
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6daab27..a8e39d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,54 @@
# 变更记录
+## v2.5.0 - 高强度使用与可维护性版(2026-07-16)
+
+- 操作记录从每次全量重写改为原子快照 + JSONL 增量日志,并支持自动压缩。
+- 2.4 历史记录自动迁移且保留归档副本;日志写入由 actor 串行处理。
+- 新增异常恢复向导,对未完成、撤销中和重做中的操作展示逐项磁盘证据;证据不足时不自动修改文件。
+- 从 Finder 拖入的文件现在从鼠标投放点附近落位,多项按网格排列,不移动已有项目。
+- 拆出 `CanvasLayoutEngine`、`FolderAccessRepository`、`OperationJournalStore` 和 `RecoveryAnalyzer`。
+- 标签、创建、重命名、Office 模板复制和文件属性读取的生产路径移到后台执行。
+- 增加可重复的 64/500/3,000 项扫描与 1,000 次日志追加性能基线。
+- 测试扩展为 8 项标准测试和 63 项完整回归。
+
+## v2.4.0 - 高强度使用稳定版(2026-07-15)
+
+- 同一张画布增加跨进程独占锁;第二个 App 会话进入只读,避免同时写坏布局和操作记录。
+- 从 2.3.2 及更早测试版迁移上次文件夹、最近空间和外观偏好,2.4 起使用稳定 Bundle ID。
+- 文件夹扫描、壁纸解码和长耗时真实文件操作移出主线程;3,000 项目录有性能回归测试。
+- 复制、移动、压缩、废纸篓和撤销/重做由串行协调器执行,显示进度,并在每个文件步骤完成后落盘。
+- 多文件拖入合并为一个事务;多选右键支持复制、剪切、副本、压缩、分享、标签、大小和废纸篓。
+- 多选拖动使用统一吸附位移,碰到边界时保持组内相对位置。
+- 待放置区升级为可搜索、可多选和批量放回的独立面板。
+- 新增标准 `swift test` 测试目标、GitHub Actions 严格构建和 56 项完整回归。
+- 新增中文维护文档、版本统一配置与独立的 2.4 发布目录。
+
+## v2.3.2 - 全屏背景修复测试版(2026-07-15)
+
+- 修复全屏或最大化视口比逻辑画布更高时,画布底部露出系统灰色背景的问题。
+- 可见背景层会扩展到完整视口,壁纸或网格连续覆盖到底部。
+- 图标、字体、间距仍按原逻辑画布统一缩放,不进行纵向拉伸。
+- 不修改已保存的图标坐标,也不把额外显示区域写入布局数据。
+- 自测扩展到 46 项,并完成普通窗口和系统全屏 GUI 视觉验证。
+
+## v2.3.1 - 窗口显示修复测试版(2026-07-15)
+
+- 普通窗口按照基准画布比例联动调整宽高,避免底部出现大块空白。
+- macOS“缩放/最大化窗口”使用当前显示器完整可见区域,不再因普通窗口比例约束而在左右留空。
+- 系统全屏继续解除窗口比例约束;退出最大化或全屏后恢复普通窗口比例。
+- 最大化、全屏和普通窗口切换只改变显示尺寸,不改写文件图标坐标。
+- 自测扩展到 44 项,并完成普通窗口、最大化、全屏和恢复窗口 GUI 回归。
+
+## v2.2.0 - 文件安全测试版(2026-07-15)
+
+- 新增持久化文件事务记录,并与布局修改按时序统一撤销/重做。
+- 重命名、移至废纸篓、新建、副本、粘贴/导入、压缩和标签纳入可恢复记录。
+- 同名项目必须明确选择保留两者、替换或取消;替换操作可完整回滚。
+- 新增“最近操作”面板、跨重启文件撤销、未完成操作核对状态与损坏记录存档恢复。
+- 根文件夹移动后自动迁移可逆操作路径。
+- 新增不包含文件名和完整路径的隐私诊断导出。
+- 自测扩展到 40 项,并完成独立 App GUI 冒烟测试。
+
## v2.1.1 - 轻度使用测试版(2026-07-15)
- 每个文件夹使用固定逻辑画布;显示器切换不再改写或保存图标坐标。
diff --git a/MAINTENANCE_2.4.md b/MAINTENANCE_2.4.md
new file mode 100644
index 0000000..9eae206
--- /dev/null
+++ b/MAINTENANCE_2.4.md
@@ -0,0 +1,245 @@
+# 空间文件夹 2.4 代码维护手册
+
+本文面向后续人工维护者。目标是让维护者不依赖原开发对话,也能判断每个模块做什么、数据放在哪里、哪些修改会触及真实文件,以及如何安全发布新版本。
+
+## 1. 产品边界
+
+必须长期保持以下规则:
+
+1. 只显示用户所选真实文件夹的第一级内容,不递归扫描。
+2. 画布图标不是快捷方式;每个图标直接对应真实 URL。
+3. 拖动图标只改变布局元数据,不移动真实文件。
+4. 双击文件交给 macOS 默认 App;双击文件夹在 Finder 中打开。
+5. “移至废纸篓”必须操作真实项目,并留下可恢复的操作记录。
+6. 外部新增文件不能打乱已有坐标;没有空位时进入待放置区。
+7. 文件系统是事实来源。布局或历史记录不能凭空制造一个不存在的文件状态。
+
+如果新需求违背这些规则,应先修改产品定义和测试,不能只在界面层绕过。
+
+## 2. 源码模块地图
+
+| 文件 | 职责 | 不应承担的职责 |
+|---|---|---|
+| `SpatialFolderApp.swift` | App 启动、菜单命令、外观注入、偏好迁移 | 文件操作和布局算法 |
+| `ContentView.swift` | 工具栏、画布、图标、右键菜单、进度条、待放置区等 SwiftUI 界面 | 直接调用 FileManager 修改真实文件 |
+| `FolderCanvasModel.swift` | 主线程状态编排:把扫描、布局、操作历史和界面命令连接起来 | 在主线程执行大目录扫描、批量复制或壁纸解码 |
+| `CanvasLayoutStore.swift` | 画布 JSON 的原子保存、备份、恢复、导入导出 | 修改真实文件 |
+| `OperationHistoryStore.swift` | 可逆文件动作、事务状态、原子保存和恢复引擎 | 管理 SwiftUI 状态 |
+| `FileOperationCoordinator.swift` | 串行执行批量复制、移动、压缩、废纸篓和撤销/重做;逐步汇报和失败回滚 | 决定按钮文案和图标坐标 |
+| `FolderScanService.swift` | 后台读取文件夹直接子项的轻量元数据 | 读取图标或递归子目录 |
+| `FileIconCache.swift` | 主线程按需读取 Finder 图标并限制缓存数量 | 首次扫描时预加载全部图标 |
+| `WallpaperImageLoader.swift` | 后台按显示尺寸降采样壁纸并缓存 CGImage | 保存画布布局 |
+| `CanvasSessionLock.swift` | 使用 `flock` 保证同一画布只有一个写入会话 | 依赖锁文件内容判断锁是否有效 |
+| `PreferencesMigrator.swift` | 从旧 Bundle ID 迁移入口偏好 | 迁移 Application Support 布局或移动用户文件 |
+| `CanvasViewport.swift` | 逻辑画布到可见视口的等比缩放和背景覆盖尺寸 | 写回图标坐标 |
+| `WindowAspectRatioController.swift` | 普通窗口锁比例,最大化/全屏解除约束 | 改变逻辑画布数据 |
+
+`FolderCanvasModel` 仍然是最大的编排层。2.4 已把耗时和高风险职责拆出,但它仍包含布局算法、书签恢复和历史编排。以后继续拆分时,应优先抽出“布局放置算法”和“书签/最近空间仓库”,不要复制状态形成两个事实来源。
+
+## 3. 启动流程
+
+启动顺序如下:
+
+1. `SpatialFolderApp` 调用 `PreferencesMigrator.migrateIfNeeded()`。
+2. 创建 `FolderCanvasModel`。
+3. 模型从稳定偏好域读取外观、最近空间和上次文件夹书签。
+4. 如能恢复上次文件夹,则调用 `open(folder:)`。
+5. 计算画布 key,尝试取得 `CanvasSessionLock`。
+6. 载入布局和操作记录,再启动文件夹扫描及文件系统监听。
+
+2.4 的稳定 Bundle ID 是 `com.dingjia.spatialfolder`。后续版本不要为了隔离测试随意更换 Bundle ID,否则 UserDefaults、系统权限和最近空间会再次分裂。测试隔离应依赖独立构建目录或专用 UserDefaults suite。
+
+## 4. 打开文件夹与刷新流程
+
+`open(folder:)` 只负责切换空间和发起后续工作。生产模式下扫描由 `FolderScanService` actor 执行:
+
+1. 生成新的 `scanGeneration`。
+2. 后台用 `FileManager.contentsOfDirectory` 读取直接子项。
+3. 只取隐藏状态、Finder 标签和资源标识,不取 NSImage。
+4. 返回主线程前同时核对文件夹 URL 和 generation。
+5. `applyScan` 合并新快照、资源标识迁移、已有位置和新增项目。
+6. 新项目寻找可预测空位;主画布满 64 项后进入待放置区。
+
+generation 校验非常重要:用户快速切换文件夹时,旧文件夹的慢扫描可能晚于新扫描返回。删除该校验会让旧结果覆盖当前空间。
+
+文件夹监听使用防抖刷新。不要把每个文件系统事件直接变成一次完整扫描,否则批量复制时会产生刷新风暴。
+
+## 5. 布局数据
+
+布局保存在:
+
+`~/Library/Application Support/SpatialFolder/Layouts`
+
+一张画布主要保存:
+
+- 根文件夹资源标识与回退路径;
+- 逻辑画布尺寸;
+- `真实路径 -> CanvasPoint`;
+- `真实路径 -> 图标/字体缩放`;
+- 待放置区路径集合;
+- 壁纸 URL;
+- 布局锁定状态和格式版本。
+
+保存必须通过 `CanvasLayoutStore` 的临时文件 + 原子替换流程。重大布局修改应先创建备份。布局格式升级时:
+
+1. 增加格式版本;
+2. 保留旧格式解码路径;
+3. 在迁移前生成备份;
+4. 增加从旧 JSON 打开的自动测试;
+5. 禁止把当前显示器尺寸无条件写成逻辑画布,否则跨屏会再次挤压坐标。
+
+## 6. 真实文件操作
+
+所有可能修改真实文件的入口必须先通过 `realFileMutationsAllowed()`。它会检查:
+
+- 已选择文件夹;
+- 布局和操作记录没有损坏阻断;
+- 当前会话不是只读;
+- 没有另一个长文件操作正在执行。
+
+长操作的标准链路:
+
+1. 模型预先生成确定的计划,例如来源、目标、是否替换。
+2. `beginFileOperation` 先把 `pending` 事务写入磁盘。
+3. 模型显示 `FileOperationProgressState`。
+4. `FileOperationCoordinator` actor 串行执行每一步。
+5. 每完成一个真实文件步骤,通过 `didApply` 回到主模型。
+6. 主模型立即追加 `ReversibleFileAction` 并原子保存操作记录。
+7. 全部完成后把事务改为 `applied`,刷新目录并补记画布位置。
+
+不要先把整批文件改完、最后才写一条记录。App 在中间崩溃时,这会留下无法解释的半批状态。
+
+### 6.1 动作类型
+
+- `materialize`:新建或复制出目标,撤销时移除目标。
+- `relocate`:从来源移动到目标,撤销时移回。
+- `discard`:真实项目进入废纸篓,撤销时恢复原路径。
+- `tags`:记录 Finder 标签前后值。
+
+### 6.2 状态含义
+
+- `pending`:操作已登记但未完成。
+- `applied`:真实变更完成,可撤销。
+- `undoing` / `redoing`:后台转换中;如进程中断,重启后进入需核对。
+- `undone`:已撤销,可重做。
+- `failed`:失败但已安全回滚。
+- `unavailable`:无法确认或无法完整回滚,必须让用户核对,不能伪装成功。
+- `viewOnly`:记录仍可查看,但布局快照已经超过内存撤销深度。
+
+### 6.3 取消语义
+
+`FileManager.copyItem` 等同步系统调用不能安全地在执行中强杀。取消按钮只取消“下一步”:
+
+1. 当前单文件系统调用结束;
+2. coordinator 检查 Task cancellation;
+3. 停止后续文件;
+4. 反向回滚本批次已完成动作。
+
+界面文案必须保持这个事实,不能承诺瞬时取消。
+
+## 7. 多选和拖放
+
+右键点击已选图标时,`contextItems(for:)` 返回整个选择集合;点击未选图标时只返回该项目。新增右键动作时应明确它是批量动作还是单项动作:
+
+- 批量:打开、显示、复制、剪切、副本、压缩、分享、标签、大小、待放置区、废纸篓。
+- 单项:重命名、显示简介。
+
+外部拖入由 `DroppedURLCollector` 等待所有 `NSItemProvider` 回调,再一次调用 `importFiles`。不要恢复逐回调导入,否则一次拖入会生成多条记录、多个进度任务,并互相覆盖冲突弹窗。
+
+多选拖动使用同一个 `dragTranslation`:先按网格吸附,再用整个选择集合的外接边界限位。不要逐图标单独夹取坐标,否则靠近边缘时会改变相对间距甚至重叠。
+
+## 8. 待放置区
+
+主画布默认容量为 8×8,共 64 项。多出的项目仍是真实文件,只是进入 `inboxIDs`,不显示在主画布。
+
+`InboxPanelView` 提供独立搜索和多选。它的搜索状态不能复用主工具栏 `searchText`,否则用户在面板搜索时会意外隐藏主画布图标。批量放回时按当前空位数量执行,空间不足的项目继续留在待放置区,并给出提示。
+
+## 9. 屏幕、窗口和壁纸
+
+逻辑画布与窗口视口是两层概念:
+
+- `canvasSize`:坐标使用的固定逻辑尺寸。
+- `CanvasViewport.displayScale`:小屏显示时的统一缩放。
+- `presentationSize`:全屏比逻辑画布更高时,扩展背景而不拉伸坐标。
+
+普通窗口由 `WindowAspectRatioController` 保持画布比例;最大化和系统全屏绕开比例约束,使用完整可见区域。切换状态只能改变显示,不能把新窗口尺寸写回图标坐标。
+
+壁纸通过 `WallpaperImageLoader` 在后台按像素档位降采样。缓存 key 包含规范化路径和像素尺寸,最多保留 8 份。系统壁纸 URL 可能随显示器变化,因此视图需要在屏幕变化时重新请求。
+
+## 10. 并发规则
+
+- `FolderCanvasModel` 标记为 `@MainActor`,所有 `@Published` 状态只能在主线程更新。
+- 文件夹扫描、壁纸解码和文件操作分别由 actor 隔离。
+- `NSImage` 只在主线程的 `FileIconCache` 中使用;后台扫描只传递 Sendable 元数据。
+- 切换文件夹时取消旧扫描,并用 generation 防止迟到结果。
+- 同一模型一次只允许一个长文件事务;跨进程再由 `CanvasSessionLock` 保证单写者。
+
+如果 Swift 6 报 Sendable 警告,不要用大范围 `@unchecked Sendable` 消音。只有像 `FileOperationEngine` 这种内部依赖可证明线程使用方式的类型,才可局部声明并用中文注释说明理由。
+
+## 11. 测试
+
+日常修改至少运行:
+
+```zsh
+swift test -Xswiftc -warnings-as-errors
+./scripts/run_self_tests.zsh
+swift build -c release -Xswiftc -warnings-as-errors
+```
+
+测试分两层:
+
+- `Tests/SpatialFolderTests`:标准 SwiftPM 快速测试,CI 直接执行。
+- `Tests/SelfTests`:56 项兼容性与真实文件事务回归,使用隔离的 `/tmp` 文件夹、偏好域、布局和模拟废纸篓。
+
+新增真实文件动作时至少增加:成功、部分失败回滚、撤销、重做和跨重启状态测试。新增窗口逻辑时至少覆盖普通窗口、最大化、全屏和小屏视口。GUI 冒烟测试不能替代数据层自动测试。
+
+## 12. 版本与打包
+
+版本单一配置位于 `config/release.env`。同时必须保证 `Assets/Info.plist` 的营销版本和构建号一致;打包脚本会主动校验。
+
+打包命令:
+
+```zsh
+./scripts/package_app.zsh
+```
+
+2.4 产物位于:
+
+- `Release/2.4.0/空间文件夹.app`
+- `Release/2.4.0/空间文件夹-v2.4.0.zip`
+
+脚本使用独立版本目录,不会覆盖冻结的 2.3.2 产物。当前是 ad-hoc 本地签名。正式商业分发还需要 Developer ID、Hardened Runtime、公证、更新渠道和隐私说明。
+
+## 13. 常见故障定位
+
+### 打开后没有文件
+
+先确认文件夹仍存在、是否进入 `folderUnavailable`、扫描 generation 是否与当前 folder 一致,再检查文件是否全部是隐藏项目。不要先重置布局。
+
+### 第二个窗口不能编辑
+
+这是会话锁设计。退出另一个 App 进程后锁会随文件描述符自动释放。锁文件中的 JSON 只是占用者说明,不要手工把“删除锁文件”当作解锁实现。
+
+### 操作记录显示需核对
+
+不要自动改为成功。先在 Finder 核对真实来源和目标,再决定存档记录或人工恢复。`unavailable` 是安全护栏。
+
+### 切换屏幕后图标挤在一起
+
+检查是否误把当前显示器尺寸写回 `canvasSize`,以及是否对每个图标分别缩放。正确做法是统一缩放整个逻辑画布。
+
+### 大目录打开卡顿
+
+检查是否在扫描阶段调用 `NSWorkspace.icon`、是否取消了图标缓存上限、是否把壁纸恢复为主线程完整解码,以及监听是否失去防抖。
+
+## 14. 下一阶段建议
+
+2.5 建议优先做工程债务而不是继续堆菜单:
+
+1. 从 `FolderCanvasModel` 抽出纯布局引擎,输入项目和画布尺寸、输出位置变化,便于更完整的单元测试。
+2. 抽出书签与最近空间仓库,减少 UserDefaults/URL bookmark 逻辑对主模型的占用。
+3. 为操作恢复增加用户可见的“核对向导”,展示动作类型而不泄露诊断导出隐私。
+4. 建立正式 Xcode 工程和 XCUITest,自动验证框选、右键批量、待放置区和窗口状态。
+5. 商业发布前完成 Developer ID 签名、公证、自动更新、崩溃报告开关和隐私政策。
+
+不建议在上述基础完成前做递归子画布、云同步或 AI 自动整理;这些功能会显著扩大真实文件一致性和冲突处理范围。
diff --git a/MAINTENANCE_2.5.md b/MAINTENANCE_2.5.md
new file mode 100644
index 0000000..4c6c428
--- /dev/null
+++ b/MAINTENANCE_2.5.md
@@ -0,0 +1,114 @@
+# 空间文件夹 2.5 代码维护手册
+
+本文是 2.5 的架构和数据安全说明。产品的长期不变量是:画布展示真实文件夹的直接子项,空间位置是独立元数据,文件系统永远是事实来源。
+
+## 1. 不可破坏的边界
+
+1. 只枚举用户选定文件夹的第一级项目。
+2. 画布图标直接对应真实 URL,不得为展示而创建替代链接或副本。
+3. 画布内拖动只修改坐标,不移动真实文件。
+4. 删除必须调用 `FileManager.trashItem`,且先写 `pending` 操作记录。
+5. 外部新项目不能重排已有坐标;主画布无位置时进入待放置区。
+6. 异常恢复不得依赖推测执行真实文件修改;证据不足时必须人工核对或仅存档日志。
+
+## 2. 2.5 模块边界
+
+| 模块 | 责任 | 不应承担 |
+|---|---|---|
+| `FolderCanvasModel` | `@MainActor` 可观察状态和 UI 流程编排 | 坐标算法、日志文件格式、生产路径短 I/O |
+| `CanvasLayoutEngine` | 初始排列、新项目空位、投放点落位、网格吸附和边界 | 读写文件、修改 SwiftUI 状态 |
+| `OperationJournalStore` | actor 串行追加、事件重放、迁移、压缩和损坏归档 | 执行真实文件操作 |
+| `FolderAccessRepository` | actor 执行冲突查询、唯一名规划、属性读取和短方案准备 | 维护画布位置或操作历史 |
+| `FileOperationCoordinator` | actor 串行执行真实文件步骤、回调逐步结果、失败回滚 | 决定用户交互、坐标或按钮文案 |
+| `RecoveryAnalyzer` | actor 只读检查异常记录的磁盘证据 | 移动、覆盖、删除或自动“修复”文件 |
+| `CanvasLayoutStore` | 布局原子保存、备份、恢复、导入导出 | 修改真实文件 |
+| `FolderScanService` | 后台读取直接子项的轻量元数据 | 递归扫描、预加载全部 Finder 图标 |
+
+`FolderCanvasModel` 仍较大,但 2.5 后它应是“编排者”而不是算法或磁盘实现者。新增职责时,如果能以值类型输入/输出表达,应优先放入独立类型或 actor。
+
+## 3. 增量操作日志
+
+目录:`~/Library/Application Support/SpatialFolder/Operations`。每张画布使用三类文件:
+
+- `.snapshot.json`:最近一次原子快照。
+- `.journal.jsonl`:从快照之后的 upsert/replaceAll 事件。
+- `.2.4.json`:首次迁移后保留的 2.4 旧记录。
+
+读取顺序是:迁移旧 JSON → 读快照 → 按行重放 JSONL → 限制最大记录数。达到事件阈值后,先原子写新快照,成功后再截断 JSONL。不得把顺序改为“先删日志、再写快照”。
+
+真实文件操作的安全顺序:
+
+1. 主模型创建 `pending` 记录。
+2. `await persistOperationRecordNow`,确认日志落盘。
+3. coordinator 执行一个真实文件步骤。
+4. 每步成功后立即追加动作并等待日志。
+5. 全部完成后改为 `applied`;失败后记录回滚结果。
+
+批量操作不得只在最后写一次。
+
+## 4. 恢复向导规则
+
+`RecoveryAnalyzer` 只分析 `pending/undoing/redoing/unavailable` 记录。它产生证据和建议,不修改磁盘。
+
+- `relocate`:仅目标存在支持已完成;仅原位置存在支持已回滚。
+- `materialize`:目标存在支持已完成;只有目标不存在且记录的撤销废纸篓路径存在才支持已回滚。
+- `discard`:原位置不存在且记录的废纸篓项存在支持已完成。
+- `tags`:当前标签必须与 before 或 after 规范化值完全一致。
+- 批量“已完成”还必须覆盖全部 `itemNames`;只完成 1/3 不能建议整批完成。
+- 存在冲突位移 `displacements` 时一律人工核对。
+
+用户按“确认已完成/已回滚”时只修正日志状态。“仅存档记录”也不修改文件。
+
+## 5. 投放点落位
+
+`CanvasFileDropDelegate` 读取 `DropInfo.location`,使用当前显示缩放比转换为逻辑画布坐标,并与整批 URL 一起传入 `importFiles` 。
+
+真实复制/移动成功后,`CanvasLayoutEngine.placeImportedItems` 才为实际生成的目标路径分配位置。算法先稳定排序候选网格,跳过已占用区域,且不修改原 `positions`。操作失败时必须丢弃待定投放点。
+
+## 6. 并发与 I/O 规则
+
+- `FolderCanvasModel` 只在主 actor 更新 `@Published` 状态。
+- 日志、文件规划/属性、实际文件操作、扫描和恢复分析由各自 actor 隔离。
+- 打开空间时,操作快照/日志在 `OperationJournalStore` actor 后台加载;加载完成前暂停真实文件和布局写入,避免新操作被迟到的历史快照覆盖。
+- 生产模式 `fileOperationsAsynchronously == true`。模型中保留的同步文件分支仅供 2.4 遗留确定性测试使用,不得从 App 启动路径关闭异步模式。
+- 开始新事务、关闭画布或打包最终状态前,必须等待已排队的日志任务。
+- 不要用大范围 `@unchecked Sendable` 消除 Swift 6 警告。
+
+## 7. 测试和性能
+
+日常验证:
+
+```zsh
+./scripts/run_standard_tests.zsh
+./scripts/run_self_tests.zsh
+./scripts/run_performance_baseline.zsh
+SDKROOT="$(./scripts/select_macos_sdk.zsh)" swift build -c release --disable-sandbox -Xswiftc -warnings-as-errors
+```
+
+- 标准测试:8 项,面向 CI 和关键架构。
+- 完整自测:63 项,覆盖数据迁移、断电尾部修复、操作回滚、布局、显示器和恢复证据。
+- 性能基线:扫描 64/500/3,000 项,以及 1,000 次日志 upsert/重放/压缩。
+
+当前机器的 Command Line Tools 默认 SDK 与编译器小版本不一致,`scripts/select_macos_sdk.zsh` 在此环境选用 15.4 SDK;完整 Xcode 环境会自动使用 Xcode 默认 SDK。
+
+## 8. 版本与打包
+
+版本唯一配置在 `config/release.env`,必须与 `Assets/Info.plist` 一致。打包:
+
+```zsh
+./scripts/package_app.zsh
+```
+
+2.5 产物位于:
+
+- `Release/2.5.0/空间文件夹.app`
+- `Release/2.5.0/空间文件夹-v2.5.0.zip`
+
+脚本会严格 Release 构建、本地 ad-hoc 签名、压缩、再解压并复验签名。不得覆盖 `Release/2.4.0` 或修改 2.4 冻结分支。
+
+## 9. 2.6 之前的优先债务
+
+1. 建立正式 Xcode 工程和 XCUITest,覆盖外部拖放、恢复向导和右键批量交互。
+2. 继续从主模型拆出书签/最近空间仓库和事务 UI 编排,但不得复制两份可观察状态。
+3. 正式分发前完成 Developer ID、Hardened Runtime、公证、自动更新和隐私政策。
+4. 保持范围约束:暂不引入递归子画布、云同步或 AI 自动整理。
diff --git a/PERFORMANCE_2.5.md b/PERFORMANCE_2.5.md
new file mode 100644
index 0000000..ff693c2
--- /dev/null
+++ b/PERFORMANCE_2.5.md
@@ -0,0 +1,38 @@
+# 空间文件夹 2.5 性能基线
+
+## 目的
+
+这份基线不用来宣称绝对性能,而是为后续版本提供可重复对照,及时发现文件扫描或日志持久化退化。
+
+## 环境与方法
+
+- 日期:2026-07-16
+- 架构:Apple Silicon(`arm64`)
+- 最低部署目标:macOS 14.0
+- SDK:`/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk`
+- 命令:`./scripts/run_performance_baseline.zsh`
+- 扫描样本:在隔离的临时目录中生成 64、500 和 3,000 个空文件,仅扫描第一级轻量元数据。
+- 日志样本:对同一条记录执行 1,000 次 upsert,触发自动压缩,再重放最终状态。
+
+## 2.5.0 基线结果
+
+| 指标 | 结果 |
+|---|---:|
+| 64 项扫描 | 0.003329 s |
+| 500 项扫描 | 0.018868 s |
+| 3,000 项扫描 | 0.115346 s |
+| 1,000 次增量 upsert | 0.094460 s |
+| 压缩后日志重放 | 0.000088 s |
+| 压缩后 JSONL 字节 | 0 B |
+| 压缩后快照字节 | 314 B |
+
+## 解读与预算
+
+- 3,000 项扫描的自动回归预算是 10 秒;当前基线远低于预算。
+- 1,000 次增量写入的回归预算是 10 秒;当前基线证明没有随记录数量反复全量重写。
+- 增量日志达到阈值后被原子快照吸收,因此此次基线中 JSONL 为 0 B。
+- 这些是微基准,不等价于包含 Finder 图标、壁纸和 SwiftUI 渲染的端到端启动时间。
+
+## 后续使用
+
+每个性能版本在同一台机器上连续运行 3 次,保留中位数。如 3,000 项扫描或 1,000 次追加较本基线增加 50% 以上,应在发布前说明原因或修复。
diff --git a/PLAN_2.3.md b/PLAN_2.3.md
new file mode 100644
index 0000000..71bef23
--- /dev/null
+++ b/PLAN_2.3.md
@@ -0,0 +1,138 @@
+# 空间文件夹 2.3 开发计划
+
+计划执行日期:2026-07-16
+版本目标:修复普通窗口比例变化后画布下方出现空白的问题。
+已确认方案:**普通窗口锁定比例,全屏解除窗口约束。**
+
+## 一、版本边界
+
+- 2.2 是冻结版本,不直接修改、不覆盖现有安装包。
+- 2.3 必须在独立开发分支或项目副本中进行。
+- 本版本只处理窗口比例和画布显示问题。
+- 不同时添加新的文件操作、右键菜单、拖拽或快捷键功能。
+- 不改变文件的真实路径,不改变已经保存的画布逻辑坐标。
+
+## 二、已确认的问题原因
+
+当前画布按照基准桌面尺寸等比例缩放,但普通窗口可以被调整为任意宽高比例。缩放逻辑主要依据可用宽度;当窗口相对更高时,画布高度不足以填满窗口,画布下方会露出灰色窗口背景。
+
+涉及范围:
+
+- `Sources/SpatialFolder/CanvasViewport.swift`
+- `Sources/SpatialFolder/ContentView.swift`
+- `Sources/SpatialFolder/SpatialFolderApp.swift`
+- 必要时增加一个很小的 AppKit 窗口访问或窗口约束组件
+
+原则上不修改 `FolderCanvasModel.swift` 的文件操作和布局保存逻辑。
+
+## 三、执行步骤
+
+### 1. 保护 2.2 基线
+
+1. 检查当前 Git 状态,保留所有现有修改,不执行清理或覆盖操作。
+2. 确认能够还原 2.2 的源代码、安装包和版本说明。
+3. 从确认后的 2.2 基线建立 `codex/2.3-window-aspect-fix` 开发分支,或建立独立项目副本。
+4. 记录修改前的窗口截图和测试结果。
+
+完成信号:2.3 的任何修改都可以单独撤销,不影响 2.2。
+
+### 2. 建立问题复现基线
+
+至少记录以下场景:
+
+- 普通窗口接近最大化。
+- 普通窗口宽而矮。
+- 普通窗口窄而高。
+- Mac 内置显示器。
+- 外接显示器。
+- 从普通窗口进入全屏,再退出全屏。
+
+同时记录画布基准尺寸、窗口内容尺寸和缩放比例,确认底部空白的触发条件。
+
+### 3. 实现普通窗口比例约束
+
+目标行为:
+
+- 普通窗口拖动宽度时,高度按比例联动。
+- 普通窗口拖动高度时,宽度按比例联动。
+- 使用“画布宽高比 + 顶部工具栏固定高度”计算窗口内容比例,不能简单锁定整个外框比例。
+- 窗口不得超出当前显示器的可见区域。
+- 保留现有最小窗口尺寸约束。
+- 允许少量像素舍入,但不能露出明显灰色空白。
+- 防御性地让窗口剩余背景与画布背景一致,避免 1~2 像素的系统舍入缝隙。
+
+### 4. 全屏模式单独处理
+
+- 进入全屏前解除普通窗口比例约束。
+- 全屏状态使用显示器提供的完整可用区域。
+- 全屏时画布保持等比例,不拉伸文件图标。
+- 退出全屏后恢复普通窗口比例约束和退出前的合理窗口尺寸。
+- 内外显示器比例不同的时候重新计算约束。
+
+### 5. 保证空间坐标不被改变
+
+本次只改变显示缩放和窗口尺寸,不重排文件:
+
+- 不调用自动排列。
+- 不重写已保存的图标坐标。
+- 不改变图标大小、字体大小和吸附网格。
+- 不因为窗口缩放产生新的布局历史记录。
+- 切换全屏或显示器后,同一文件之间的空间关系保持不变。
+
+### 6. 自动检查与人工回归
+
+自动检查:
+
+- 不同比例下的画布缩放计算。
+- 最小窗口尺寸。
+- 内置屏幕和外接屏幕尺寸切换。
+- 普通窗口与全屏状态转换。
+- 不产生负数尺寸、无穷值或越界缩放。
+
+人工回归:
+
+- 单击选择。
+- 鼠标双向框选。
+- 多选拖动。
+- 双击打开文件和文件夹。
+- 右键菜单。
+- 新建文件夹、Word、Excel、PowerPoint。
+- 从外部拖入文件。
+- 锁定和解锁布局。
+- 切换壁纸与系统外观。
+- 关闭并重新打开 App 后图标位置不变。
+
+### 7. 生成 2.3 测试版
+
+1. 更新内部版本号为 `2.3.0`,明确标注测试版。
+2. 先运行开发版和自检,不覆盖 `/Applications` 中现有的 2.2。
+3. 生成独立的“空间文件夹 2.3 测试版.app”。
+4. 由用户在内置显示器、外接显示器和全屏模式下验收。
+5. 用户确认后,再冻结、提交 Git、更新说明并打包正式版本。
+
+## 四、验收标准
+
+必须全部满足:
+
+- 普通窗口不能被调整成任意比例。
+- 普通窗口底部不再出现截图中的大块灰色空白。
+- 最大化和全屏显示正常,没有裁切、拉伸或额外空白。
+- 进入和退出全屏后窗口尺寸正常。
+- 内置屏幕与外接屏幕之间切换正常。
+- 原有图标位置和相对空间关系不变。
+- 多选、拖动、双击、右键和文件操作没有回归。
+- 2.2 冻结版本和安装包保持不变。
+
+## 五、停止条件
+
+出现以下任一情况时,停止扩大修改范围并回到原因定位:
+
+- 必须重写 `FolderCanvasModel` 才能继续。
+- 图标坐标发生永久改变。
+- 全屏修复导致普通窗口回归,或相反。
+- 需要同时修改文件操作、复制粘贴或拖拽逻辑。
+- 无法确认当前源代码对应冻结的 2.2 基线。
+
+## 六、明天打开 Codex 后的首条指令
+
+> 按照 `PLAN_2.3.md` 开始修复。先保护和确认 2.2 冻结基线,再建立 2.3 独立开发分支;只处理普通窗口锁定比例、全屏解除约束的问题。完成开发版和回归检查后先让我验证,不要覆盖或修改 2.2 安装包。
diff --git a/PLAN_2.4.md b/PLAN_2.4.md
new file mode 100644
index 0000000..a50e9f0
--- /dev/null
+++ b/PLAN_2.4.md
@@ -0,0 +1,76 @@
+# 空间文件夹 2.4 开发与验收计划
+
+## 版本定位
+
+2.4 定位为“高强度使用稳定版”。本版本不扩展递归子画布、AI 自动布局和云同步,重点是让现有真实文件功能在大目录、批量操作、版本升级和异常中断场景下保持安全、流畅、可维护。
+
+## 必须满足的产品目标
+
+| 编号 | 目标 | 验收证据 |
+|---|---|---|
+| R1 | 同一张画布只能由一个 App 进程写入 | 第二个会话获取锁失败并进入只读/阻止写入;自动测试覆盖锁释放和过期进程恢复 |
+| R2 | 从 2.3.2 升级后恢复上次文件夹、最近空间和外观 | 偏好迁移测试;首次启动 2.4 后写入稳定偏好域 |
+| R3 | 大目录扫描和图标读取不长时间占用主线程 | 扫描服务独立于主模型;性能测试覆盖 3,000 项目录 |
+| R4 | 复制、移动、压缩等长操作有进度且不会并发改坏记录 | 串行文件操作协调器;每一步落盘;失败后能恢复或明确进入需核对状态 |
+| R5 | 多文件拖入被当作一个批次 | 一次拖入生成一个事务;冲突选择不会被后续回调覆盖 |
+| R6 | 多选右键操作作用于整个选择集合 | 复制、剪切、制作副本、压缩、标签和移至废纸篓覆盖选择集合 |
+| R7 | 多选拖动保持相对位置 | 使用统一受限位移,不因单个项目碰到边界而压缩或重叠 |
+| R8 | 超过 64 项时仍能高效管理待放置项目 | 可搜索、可多选的待放置区面板,不依赖超长菜单 |
+| R9 | 测试进入标准工程链路 | `swift test` 可直接执行;GitHub Actions 自动运行测试和严格 Release 构建 |
+| R10 | 代码具备人工维护条件 | 新增核心类型和复杂流程包含中文注释;提供完整维护文档和模块关系说明 |
+
+## 工程目标
+
+1. 将 `FolderCanvasModel` 中的会话锁、目录扫描、图标/壁纸加载和真实文件执行拆分为独立类型。
+2. 真实文件操作保持“文件系统是事实来源”的原则;画布只保存布局元数据。
+3. 所有跨线程结果都在回到主线程前携带会话标识,避免切换文件夹后旧扫描结果覆盖新空间。
+4. 2.4 使用稳定 Bundle ID 和稳定 App 名称;以后升级不再通过改 Bundle ID 隔离版本。
+5. 发布脚本从一个版本配置读取名称和版本,避免 README、Info.plist 和脚本继续漂移。
+
+## 分阶段实施
+
+### 阶段 A:安全底座
+
+- 画布会话锁。
+- 2.3.2 偏好迁移。
+- 文件操作逐步骤状态记录与异常恢复入口。
+
+### 阶段 B:性能与架构
+
+- 后台目录扫描和图标缓存。
+- 壁纸缓存与按显示尺寸解码。
+- 长耗时真实文件操作串行化、进度化。
+
+### 阶段 C:桌面交互完善
+
+- 聚合多文件拖入。
+- 多选上下文操作。
+- 保持相对位置的多选拖动。
+- 待放置区面板。
+
+### 阶段 D:工程化与发布
+
+- 标准测试目标和 CI。
+- 2.4 版本打包、签名校验和 GUI 冒烟测试。
+- 中文维护文档、发布说明和 README 同步。
+
+## 完成定义
+
+只有当 R1—R10 均有对应代码、测试或运行证据,全部自动测试通过,严格 Release 构建通过,并完成独立 2.4 App 的普通窗口、最大化、全屏和文件操作冒烟验证时,2.4 才视为完成。
+
+## 实施结果
+
+| 编号 | 状态 | 已完成证据 |
+|---|---|---|
+| R1 | 完成 | `CanvasSessionLock` + 跨锁/双模型只读测试 |
+| R2 | 完成 | 稳定 Bundle ID + `PreferencesMigrator` + 2.3.2 偏好迁移测试 |
+| R3 | 完成 | `FolderScanService`、按需图标、后台壁纸解码 + 3,000 项测试 |
+| R4 | 完成 | `FileOperationCoordinator`、进度/取消、逐步落账、失败回滚和异步模型测试 |
+| R5 | 完成 | `DroppedURLCollector` 聚合全部 provider 后一次导入 |
+| R6 | 完成 | 多选右键批量 API、自动测试和 GUI 菜单验证 |
+| R7 | 完成 | 统一吸附/限位位移 + 相对位置与边界测试 |
+| R8 | 完成 | 可搜索、多选、批量放回的 `InboxPanelView` + GUI 验证 |
+| R9 | 完成 | 6 项 `swift test`、56 项完整回归、GitHub Actions 严格 Release 构建 |
+| R10 | 完成 | 新模块中文注释、主模型职责分区、`MAINTENANCE_2.4.md` |
+
+最终验证已覆盖:严格 Debug/Release 编译、App 本地签名、ZIP 解压后签名、70 项 64+6 布局、反向框选、多选右键、批量副本与撤销、普通窗口、最大化和系统全屏。GUI 使用隔离测试目录,结束后恢复原工作空间并清理测试数据。
diff --git a/PLAN_2.5.md b/PLAN_2.5.md
new file mode 100644
index 0000000..05be229
--- /dev/null
+++ b/PLAN_2.5.md
@@ -0,0 +1,68 @@
+# 空间文件夹 2.5 开发与验收计划
+
+## 版本定位
+
+2.5 定位为“高强度使用与可维护性版”。以冻结的 2.4 为基线,不扩展递归画布、AI 排列、云同步等新方向,只主攻:增量操作日志、主模型拆分、剩余 I/O 后台化,并加入“拖到哪里就放到哪里”和“异常恢复向导”。
+
+## 不可破坏的产品原则
+
+1. 文件夹与文件始终是真实项目;画布只保存布局元数据。
+2. 文件操作必须先记录可恢复的 `pending` 状态,再修改真实文件;每完成一步立即追加日志。
+3. 退出、崩溃或断电后,不能把状态未知的操作伪装成成功;必须进入可解释、可处理的恢复流程。
+4. 新项目不能挤动已有图标。外部拖入时优先从投放点附近按网格落位,空间不足的项目进入待放置区。
+5. 2.4 的画布布局、偏好和操作记录必须无损升级;2.4 分支和发布物保持冻结。
+
+## 必须满足的目标
+
+| 编号 | 目标 | 验收证据 |
+|---|---|---|
+| R1 | 操作历史改为增量日志 | 每次 upsert/批量更新只追加一条 JSONL 事件,不再全量重写 200 条记录;测试验证事件重放 |
+| R2 | 日志可压缩且可从 2.4 迁移 | 旧 `.json` 自动迁移为快照 + 日志;达到阈值后生成原子快照并截断日志;迁移/压缩测试 |
+| R3 | 日志持久化不在主线程执行 | `OperationJournalStore` actor 串行写入;真实文件操作启动前 `await` pending 落盘;并发顺序测试 |
+| R4 | 主模型职责明显收敛 | 坐标算法、操作日志、真实文件访问/命令从 `FolderCanvasModel` 拆为独立模块;维护文档列明边界 |
+| R5 | 剩余真实文件 I/O 后台化 | 标签、创建、模板复制、重命名、属性读取以及生产路径的复制/移动/删除均不在 `@MainActor` 执行 |
+| R6 | 外部文件按投放点落位 | 单项落到投放点吸附坐标;多项从该点附近按网格排列;不移动已有项目;溢出进入待放置区 |
+| R7 | 异常恢复向导 | 对 `pending/undoing/redoing/unavailable` 记录展示逐项证据、建议动作和明确决策;可标记已完成、已回滚或仅存档 |
+| R8 | 保持 2.4 回归安全 | 2.4 的 56 项自测、标准测试与严格 Debug/Release 构建全部通过,并增加 2.5 专项测试 |
+| R9 | 性能有可重复基线 | 记录 64/500/3000 项扫描、1000 次日志追加/重放/压缩时间与日志写入字节量 |
+| R10 | 形成可交付 2.5 | 版本号、README、变更记录、维护文档、App、ZIP、签名与解压复验一致 |
+
+## 模块目标
+
+```text
+FolderCanvasModel(只保留可观察状态和 UI 编排)
+├── CanvasLayoutEngine 坐标、吸附、容量、投放点布局
+├── OperationJournalStore actor;增量写入、重放、迁移、压缩
+├── FolderAccessRepository actor;短文件 I/O 与元数据读取
+├── FileOperationCoordinator actor;批量/长操作、撤销重做
+└── RecoveryAnalyzer 只读分析磁盘证据,生成恢复建议
+```
+
+## 实施顺序
+
+1. 建立 2.5 分支、基线测试与计划。
+2. 引入操作日志格式、迁移和压缩,先以存储测试证明数据安全。
+3. 抽出布局算法和文件访问仓库,再把生产路径的剩余 I/O 接入 actor。
+4. 在同一条导入数据流中传递投放坐标,并保存最终布局元数据。
+5. 基于日志中的异常记录实现恢复分析与向导,不允许向导直接猜测文件状态。
+6. 跑专项测试、全量回归、严格构建、性能基线和 GUI 冒烟测试。
+7. 完成审视报告、文档、版本号、打包与签名复验。
+
+## 完成定义
+
+只有 R1—R10 都有当前代码和测试/运行证据,2.4 数据升级安全,真实文件安全顺序未被异步化破坏,并且独立的 2.5 App/ZIP 通过签名和启动验证,才视为 2.5 完成。
+
+## 实施记录(2026-07-16)
+
+| 目标 | 实现与验证证据 |
+|---|---|
+| R1 | `OperationJournalStore.swift` 实现 upsert/replaceAll JSONL 事件;1,000 次 upsert 回归通过 |
+| R2 | 旧 `.json` 迁移为快照并归档为 `.2.4.json`;重放和压缩测试通过 |
+| R3 | actor 串行写入,真实文件操作前通过 `persistOperationRecordNow` 等待 pending 落盘 |
+| R4 | 已拆出 `CanvasLayoutEngine`、`OperationJournalStore`、`FolderAccessRepository`、`RecoveryAnalyzer`;边界见 `MAINTENANCE_2.5.md` |
+| R5 | 生产路径的标签、创建、模板复制、重命名和属性读取均进入 actor;后台短操作回归通过 |
+| R6 | `CanvasFileDropDelegate` 传递逻辑投放坐标,布局引擎完成吸附/避让/溢出;已有坐标不变测试通过 |
+| R7 | 恢复向导展示逐项证据;批量动作覆盖不全、目标无证据消失和冲突位移均强制人工核对 |
+| R8 | 8/8 标准测试、63/63 完整回归通过;严格 Debug/Release 构建通过 |
+| R9 | `scripts/run_performance_baseline.zsh` 可重复执行,结果已记入 `PERFORMANCE_2.5.md` |
+| R10 | 版本、README、变更记录、发布说明和维护手册已更新;2.5 App/ZIP 已完成签名、解压复验和隔离启动冒烟测试 |
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..2d5a663
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,24 @@
+{
+ "originHash" : "c4886be5116d825340e3fcff8987bff30d2d9e3691478787747463afa4c9d0bf",
+ "pins" : [
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
+ "version" : "601.0.1"
+ }
+ },
+ {
+ "identity" : "swift-testing",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-testing.git",
+ "state" : {
+ "branch" : "swift-6.1.3-RELEASE",
+ "revision" : "1d1f7e489c9c606ae6c2caa10385372736ef4a39"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index c5c911b..7f8395e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,5 +1,17 @@
// swift-tools-version: 6.0
import PackageDescription
+import Foundation
+
+let commandLineToolsLibrary = "/Library/Developer/CommandLineTools/Library/Developer/usr/lib"
+let testingLinkerSettings: [LinkerSetting] = FileManager.default.fileExists(
+ atPath: "\(commandLineToolsLibrary)/lib_TestingInterop.dylib"
+) ? [
+ .unsafeFlags([
+ "-L", commandLineToolsLibrary,
+ "-Xlinker", "-rpath",
+ "-Xlinker", commandLineToolsLibrary
+ ])
+] : []
let package = Package(
name: "SpatialFolder",
@@ -7,7 +19,24 @@ let package = Package(
products: [
.executable(name: "SpatialFolder", targets: ["SpatialFolder"])
],
+ dependencies: [
+ // 6.1.3 使用 SwiftSyntax 601,可兼容本机备用 macOS 15.4 SDK;测试 API
+ // 对本项目足够,且不影响使用 Swift 6.3 构建正式 App。
+ .package(
+ url: "https://github.com/swiftlang/swift-testing.git",
+ revision: "swift-6.1.3-RELEASE"
+ )
+ ],
targets: [
- .executableTarget(name: "SpatialFolder", resources: [.process("Resources")])
+ .executableTarget(name: "SpatialFolder", resources: [.process("Resources")]),
+ .testTarget(
+ name: "SpatialFolderTests",
+ dependencies: [
+ "SpatialFolder",
+ .product(name: "Testing", package: "swift-testing")
+ ],
+ path: "Tests/SpatialFolderTests",
+ linkerSettings: testingLinkerSettings
+ )
]
)
diff --git a/README.md b/README.md
index 2e6c67d..a0f6c0f 100644
--- a/README.md
+++ b/README.md
@@ -4,101 +4,112 @@
选择一个真实文件夹后,App 会把它的第一级文件和子文件夹呈现在类似 macOS 桌面的画布上。你可以按自己的空间习惯自由摆放;再次打开时,所有图标仍停留在原来的位置。
-> 当前版本:**v2.1.1 轻度使用测试版**
+> 当前版本:**v2.5.0 高强度使用与可维护性版**
>
-> 适合个人与小范围朋友试用;重要文件仍建议保持正常备份。
+> 已具备长期个人使用和小范围试用所需的会话保护、批量操作、异常回滚和自动测试;重要文件仍应保持正常备份。
## 下载和安装
-在 GitHub 右侧的 **Releases** 中下载 `Spatial-Folder-v2.1.1-macOS-arm64.zip`:
+从 GitHub Releases 下载 `空间文件夹-v2.5.0.zip`:
-1. 解压 ZIP,把 `空间文件夹 2.1.1.app` 拖入“应用程序”。
+1. 解压后,把 `空间文件夹.app` 拖入“应用程序”。
2. 第一次启动时,右键 App,选择“打开”。
-3. 如果 macOS 仍然阻止启动,在“系统设置 → 隐私与安全性”中确认打开。
+3. 如 macOS 仍阻止启动,在“系统设置 → 隐私与安全性”中确认打开。
4. 选择一个文件夹,即可建立它自己的空间画布。
系统要求:macOS 14 Sonoma 或更高版本、Apple Silicon Mac。
-## 它适合什么场景
-
-- 不想把文件堆在系统桌面上,但需要依靠位置记忆文件。
-- 希望把项目资料按“左上待处理、中央进行中、右侧归档”等空间区域组织。
-- 不想改变真实文件结构,也不想创建一堆替身链接。
-- 在外接显示器和 Mac 内置屏幕之间切换工作。
-
-## 主要功能
+## 核心能力
### 稳定的空间画布
- 一个真实文件夹对应一张独立画布,只展示该文件夹的第一级内容。
-- 自由拖动、网格吸附、框选、多选和多文件拖动。
+- 支持自由拖动、网格吸附、双向框选、多选和整组拖动。
- 保存每个图标的位置、图标大小和文字大小。
-- 8×8 主画布最多容纳 64 项;更多项目进入“待放置区”,不会打乱已有布局。
-- 新出现的文件优先进入空闲位置,已有文件不会自动重排。
-- 支持锁定布局、撤销、重做、布局备份、导入和导出。
-
-### 跨显示器稳定性
-
-- 每个文件夹保存固定的逻辑画布。
-- 从外接显示器切换到 Mac 内置屏幕后,只缩放视图,不改写文件坐标。
-- 图标、字体、间距和壁纸使用同一比例缩放,避免小屏幕上挤成一团。
-- 如旧版布局已经被压缩,可在大屏幕上使用“布局 → 将当前显示器设为基准画布”。
+- 8×8 主画布最多容纳 64 项;更多项目进入可搜索、可多选的待放置区。
+- 新出现的文件进入可预测空位,不会自动重排已有文件。
+- 支持布局锁定、撤销、重做、备份、导入和导出。
### 真实文件操作
-- 双击文件:用 macOS 默认应用打开。
-- 双击文件夹:在 Finder 中打开。
-- 支持重命名、复制、剪切、粘贴、制作副本、压缩和分享。
-- 支持 Finder 原生颜色标签、显示简介和“在 Finder 中显示”。
+- 双击文件:用 macOS 默认应用打开;双击文件夹:在 Finder 中打开。
+- 支持重命名、复制、剪切、粘贴、制作副本、压缩、分享和显示简介。
+- 支持 Finder 原生颜色标签和“在 Finder 中显示”。
- 删除会把真实文件移入 macOS 废纸篓,并非只隐藏画布入口。
-- 可从 Finder 把文件拖入画布,也可把文件拖到其他应用。
+- 可从 Finder 把多个文件一次拖入画布,也可把画布文件拖到其他应用。
+- 从 Finder 拖入时,新图标从鼠标投放点附近按网格落位;已有图标不会被重排。
- 可静默新建文件夹、Excel 工作簿、Word 文档和 PowerPoint 演示文稿。
-### 外观和长期使用
+### 2.4 批量交互
+
+- 一次拖入多个文件只生成一个批次、一个进度任务和一次冲突选择。
+- 右键已框选项目时,复制、剪切、副本、压缩、分享、标签、大小、待放置区和废纸篓作用于整个选择集合。
+- 多选拖动使用统一网格位移,靠近边界时保持组内相对位置。
+- 待放置区支持独立搜索、全选当前结果和批量放回主画布。
+
+### 文件安全和高强度使用
-- 默认使用当前显示器的系统桌面壁纸,也可选择自定义图片。
+- 同一张画布只能由一个 App 进程写入;第二个会话显示只读,避免同时写坏数据。
+- 长耗时复制、移动、压缩、废纸篓和撤销/重做在后台串行执行,并显示进度。
+- 每完成一个真实文件步骤就立即保存事务记录;中途失败会回滚已完成内容,无法确认时明确进入“需核对”。
+- 同名冲突不会静默覆盖,必须选择“保留两者”、“替换”或“取消”。
+- 文件操作撤销记录跨 App 重启保留;替换操作也可完整恢复。
+- 可导出不包含文件名、文件夹名或完整路径的诊断 JSON。
+
+### 2.5 恢复与性能
+
+- 操作历史改为“原子快照 + JSONL 增量日志”;每个文件步骤只追加事件,达到阈值后自动压缩。
+- 2.4 的操作历史首次打开时自动迁移,旧文件会保留为归档副本。
+- 日志写入、标签、创建、重命名、模板复制和文件属性读取均移到后台串行执行。
+- 新增异常恢复向导:崩溃或强制退出后,依据真实磁盘状态逐项展示证据;证据不足时绝不自动猜测。
+- 布局、日志、文件访问和恢复分析已从主模型拆分为可独立测试的模块。
+
+### 性能、显示器与外观
+
+- 文件夹扫描、壁纸解码和长文件操作不在主线程执行。
+- 图标按可见需要加载并设置缓存上限;3,000 项目录有性能回归测试。
+- 每个文件夹使用固定逻辑画布,跨显示器只缩放视图,不改写图标坐标。
+- 普通窗口保持画布比例;最大化和全屏使用完整可见区域,背景连续覆盖到底部。
+- 默认跟随当前显示器系统壁纸,也可选择自定义壁纸。
- 支持跟随系统、浅色和深色外观。
-- 自动恢复上次打开的文件夹。
-- Finder 中重命名文件后保留原位置。
-- 原文件夹被移动时尝试自动恢复,也支持手动重新关联。
-- 文件夹变化采用防抖刷新和图标缓存,降低持续使用时的资源消耗。
+- 自动恢复上次文件夹;Finder 外部重命名和根文件夹移动会尽量保留布局。
## 安全边界
-- 画布展示的都是真实文件,不是链接或副本。
+- 画布展示的都是真实文件,不是链接或显示副本。
- 在画布中拖动图标只改变布局,不会移动真实文件。
-- 重命名、复制、粘贴和移至废纸篓会真实作用于文件系统。
-- 布局元数据保存在 `Application Support/SpatialFolder/Layouts`,与用户文件分开。
+- 重命名、复制、粘贴、标签和废纸篓会真实作用于文件系统。
+- 布局元数据保存在 `Application Support/SpatialFolder/Layouts`。
+- 可逆文件操作记录保存在 `Application Support/SpatialFolder/Operations`。
- App 不上传文件,当前版本不包含账户、云同步或遥测服务。
## 当前限制
-- 只展示所选文件夹的第一级内容,不递归展示子文件夹。
-- 文件夹会在 Finder 中打开,不会进入下一层画布。
-- 第三方 Finder 扩展的右键菜单无法保证迁移;可使用“在 Finder 中显示”。
-- 当前安装包使用本地签名,尚未进行 Apple Developer ID 公证。
-- 不要同时运行旧版 2.1 和 2.1.1;两者会读取同一份布局数据。
-- 尚未专门处理 iCloud 多设备同时修改布局时的冲突。
+- 只展示所选文件夹的第一级内容;文件夹会在 Finder 打开,不进入递归子画布。
+- 第三方 Finder 扩展的右键菜单无法保证迁移,可使用“在 Finder 中显示”。
+- 当前安装包使用本地临时签名,尚未进行 Apple Developer ID 公证。
+- 布局撤销快照只在当前 App 进程内有效;真实文件操作记录可跨重启。
+- 如果文件在操作后又被其他 App 移动或删除,撤销会停止并提示核对,不会猜测新位置。
+- 尚未处理 iCloud 多设备同时修改同一画布的冲突。
+- 旧测试版不认识 2.5 的会话锁和增量日志,不要同时使用旧版和 2.5 编辑同一个真实文件夹。
## 开发与测试
```zsh
./scripts/run_dev.zsh
+./scripts/run_standard_tests.zsh
./scripts/run_self_tests.zsh
+./scripts/run_performance_baseline.zsh
./scripts/package_app.zsh
```
-2.1.1 包含 18 项布局可靠性自测,覆盖跨屏往返、布局迁移、备份恢复、重命名、根文件夹移动、撤销和锁定等场景。
+2.5 包含 8 项标准 SwiftPM 测试和 63 项完整回归,GitHub Actions 会自动运行测试及严格 Release 构建。
-详细设计见 [2.1.1 跨显示器布局技术说明](TECHNICAL_2.1.1.md)。
+- [2.5 发布说明](RELEASE_NOTES_2.5.md)
+- [2.5 开发与验收计划](PLAN_2.5.md)
+- [2.5 代码维护手册](MAINTENANCE_2.5.md)
+- [2.5 性能基线](PERFORMANCE_2.5.md)
## 反馈建议
-这是轻度使用测试版。反馈问题时,建议附上:
-
-- macOS 版本和 Mac 型号;
-- 是否连接外接显示器及两块屏幕的分辨率;
-- 文件数量和复现步骤;
-- 问题发生前最后一次操作。
-
-请勿在反馈中上传包含隐私内容的真实文件。
+反馈问题时请附上 macOS 版本、Mac 型号、显示器分辨率、文件数量和可重复步骤。请勿上传包含隐私内容的真实文件;优先使用 App 导出的诊断 JSON。
diff --git a/RELEASE_NOTES_2.2.md b/RELEASE_NOTES_2.2.md
new file mode 100644
index 0000000..5624a38
--- /dev/null
+++ b/RELEASE_NOTES_2.2.md
@@ -0,0 +1,31 @@
+# 空间文件夹 v2.2 测试版
+
+2.2 的主题是“真实文件操作可恢复”。它保留 2.1.1 的画布、跨屏稳定性和 Finder 式交互,新增持久化操作记录、统一撤销/重做和显式同名冲突保护。
+
+## 重点变化
+
+- `Command+Z` 撤销最新的布局或文件操作;`Shift+Command+Z` 重做。
+- 撤销“移至废纸篓”会恢复真实文件和原画布位置。
+- 新建文件夹、Office 文档、副本、压缩包和导入文件均可撤销/重做。
+- 粘贴、拖入或重命名出现同名项目时,必须选择保留两者、替换或取消。
+- 最近操作面板可查看操作状态并导出隐私安全诊断信息。
+
+## 安装
+
+1. 解压 `空间文件夹-v2.2-测试版.zip`。
+2. 将 `空间文件夹 2.2 测试版.app` 拖入“应用程序”。
+3. 首次启动时右键 App 选择“打开”。
+
+2.2 使用独立的 App 标识和名称,不会覆盖冻结的 2.1.1 App。两个版本仍会读取同一份画布布局,请不要同时编辑同一空间。
+
+## 测试范围
+
+- 40 项自动回归测试。
+- Swift “警告即错误”完整编译。
+- 独立 App GUI 验证:文件夹选择、系统壁纸、图标拖动、统一撤销/重做、冲突选择、最近操作、诊断导出和重启恢复。
+
+## 已知边界
+
+- 布局撤销快照仅在当前运行期间有效;文件操作记录可跨重启。
+- 文件若被其他 App 再次移动或删除,撤销会停止并报错,不会猜测或覆盖。
+- 安装包是本地签名,尚未经 Apple Developer ID 公证。
diff --git a/RELEASE_NOTES_2.3.1.md b/RELEASE_NOTES_2.3.1.md
new file mode 100644
index 0000000..7151c4b
--- /dev/null
+++ b/RELEASE_NOTES_2.3.1.md
@@ -0,0 +1,25 @@
+# 空间文件夹 v2.3.1 测试版
+
+2.3.1 专门修复窗口比例与最大化显示问题,不改变真实文件操作和已保存的图标坐标。
+
+## 修复内容
+
+- 普通窗口保持基准画布比例:改变宽度时高度联动,改变高度时宽度联动。
+- 工具栏使用固定显示高度,不参与画布比例拉伸。
+- macOS“缩放/最大化窗口”解除普通窗口比例约束,使用当前显示器完整可见区域。
+- 系统全屏继续解除窗口比例约束并填满屏幕。
+- 退出最大化或全屏后恢复普通窗口比例。
+- 窗口尺寸受当前显示器可见范围保护,不会被调整到屏幕之外。
+
+## 安全边界
+
+- 2.3.1 使用独立 App 名称和标识,不会覆盖 2.2 或原 2.3 测试版。
+- 只修改窗口与画布显示层,不修改文件路径、布局坐标和真实文件操作。
+- 不要同时使用两个版本编辑同一个真实文件夹。
+
+## 验证情况
+
+- 44 项自动测试全部通过。
+- Release 模式按“警告即错误”完成编译。
+- 安装包签名和解压后签名校验通过。
+- GUI 验证覆盖:普通窗口放大/缩小、最大化、恢复最大化、进入全屏、退出全屏、图标选择、图标拖动和画布右键菜单。
diff --git a/RELEASE_NOTES_2.3.2.md b/RELEASE_NOTES_2.3.2.md
new file mode 100644
index 0000000..46d21c1
--- /dev/null
+++ b/RELEASE_NOTES_2.3.2.md
@@ -0,0 +1,26 @@
+# 空间文件夹 v2.3.2 测试版
+
+2.3.2 修复全屏模式底部露出灰色窗口背景的问题。
+
+## 修复内容
+
+- 当全屏或最大化视口高于固定逻辑画布时,显示背景自动延伸到可用区域底部。
+- 系统桌面壁纸、自定义壁纸和网格背景不会再在底部突然中断。
+- 普通窗口仍保持画布比例。
+- 最大化窗口仍使用显示器完整可见区域。
+- 系统全屏仍解除普通窗口比例约束。
+
+## 空间数据保持不变
+
+- 图标位置、图标大小、字体大小和网格吸附不变。
+- 背景延伸只是显示处理,不扩大或改写保存的逻辑画布。
+- 文件操作、撤销/重做和真实文件路径没有修改。
+
+## 验证情况
+
+- 46 项自动测试全部通过。
+- Release 模式按“警告即错误”完成编译。
+- 安装包签名和解压后签名校验通过。
+- GUI 验证覆盖普通窗口和系统全屏;全屏画面已连续覆盖到屏幕底部。
+
+2.3.2 使用独立 App 名称和标识,不会覆盖 2.2、2.3 或 2.3.1。不要同时使用多个版本编辑同一个真实文件夹。
diff --git a/RELEASE_NOTES_2.4.md b/RELEASE_NOTES_2.4.md
new file mode 100644
index 0000000..0eb22b8
--- /dev/null
+++ b/RELEASE_NOTES_2.4.md
@@ -0,0 +1,37 @@
+# 空间文件夹 v2.4.0
+
+2.4 是面向高强度日常使用的稳定性版本。它不改变“一个真实文件夹就是一张空间画布”的产品方向,重点解决大目录、批量文件操作、多开、升级和长期维护问题。
+
+## 用户可见变化
+
+- 同一张画布同时只能由一个 App 进程编辑。第二个会话仍可浏览和打开文件,但会明确显示只读状态。
+- 多文件从 Finder 一次拖入时,只出现一个批次、一条操作记录和一次同名冲突选择。
+- 框选多个项目后,右键的复制、剪切、制作副本、压缩、分享、标签、大小、待放置区和废纸篓会作用于整个选择集合。
+- 多选拖动保持图标之间的相对位置;整组在画布边缘统一停止。
+- 待放置区改为独立面板,可搜索、全选当前结果、多选并批量放回。
+- 长耗时复制、移动、压缩、废纸篓和撤销/重做显示进度;允许安全取消的操作会在当前系统调用结束后回滚本批次。
+- 目录刷新和壁纸加载不再长时间占用界面线程,大文件夹打开更平稳。
+
+## 数据安全
+
+- 文件系统始终是事实来源;画布只保存位置、大小、待放置状态和壁纸等元数据。
+- 每个真实文件步骤完成后立即更新操作记录;崩溃后不会把未完成批次伪装成成功。
+- 批量操作中途失败时优先自动回滚;无法完整回滚会进入“需核对”状态,并停止猜测性修改。
+- 删除仍然调用 macOS 废纸篓,绝不是只删除画布链接。
+- 2.4 读取固定的 Application Support 数据目录,并迁移旧版入口偏好;不会移动用户文件。
+
+## 性能与验证
+
+- 标准测试:6 项,覆盖跨进程锁、3,000 项扫描、批量失败回滚和窗口状态。
+- 完整回归:56 项,覆盖布局、真实文件事务、撤销/重做、跨屏、全屏、升级和批量交互。
+- 编译要求:Swift 6 严格并发检查,警告按错误处理。
+- 系统要求:macOS 14 或更高版本,Apple Silicon Mac。
+
+## 安装说明
+
+1. 解压 `空间文件夹-v2.4.0.zip`。
+2. 将 `空间文件夹.app` 拖入“应用程序”。
+3. 首次启动如被 Gatekeeper 阻止,请右键 App 选择“打开”。
+4. 2.4 会尝试恢复旧测试版最后使用的文件夹、最近空间和外观设置。
+
+当前发布包使用本地临时签名,尚未使用 Developer ID 公证。首次交给朋友使用前,建议保留真实文件的常规备份。
diff --git a/RELEASE_NOTES_2.5.md b/RELEASE_NOTES_2.5.md
new file mode 100644
index 0000000..b8726f4
--- /dev/null
+++ b/RELEASE_NOTES_2.5.md
@@ -0,0 +1,39 @@
+# 空间文件夹 v2.5.0
+
+2.5 是面向高强度使用的性能与可恢复性版本。它不改变“一个真实文件夹就是一张空间画布”的产品方向,功能上只增加投放点落位和异常恢复向导,主要投入用于减少写盘、移出主线程 I/O 和拆分主模型。
+
+## 用户可见变化
+
+- 从 Finder 拖入文件时,新图标从鼠标投放点附近按网格落位,不再统一进入默认首个空位。
+- 一次拖入多项时,从投放点开始稳定排列;不会挤动画布中的已有项目,没有空位的项目仍进入待放置区。
+- 强制退出、崩溃或异常中断后,工具栏会提示“待恢复”。向导展示每个文件步骤的真实磁盘证据,用户可确认已完成、已回滚,或仅存档记录。
+
+## 数据安全
+
+- 文件系统仍是唯一事实来源;恢复向导只读检查磁盘并修正日志解释,不会为“恢复”而移动、覆盖或删除文件。
+- 批量操作必须同时具备完整动作覆盖和一致的磁盘证据,才会建议“已完成”。
+- 新建项目消失且没有明确废纸篓路径时,不会猜测为“已回滚”。
+- 2.4 的操作历史首次读取后会迁移为新格式,原文件保留为 `.2.4.json` 归档。
+
+## 性能与架构
+
+- 操作记录由“每次重写整份 JSON”改为“快照 + JSONL 增量事件”,达到阈值后使用原子快照自动压缩。
+- `OperationJournalStore` actor 保证记录串行写入;修改真实文件前会先等待 `pending` 状态落盘。
+- 标签、创建、重命名、Office 模板复制和文件属性读取移入后台 actor。
+- 布局算法、文件访问、增量日志和恢复分析从主模型拆出,减少后续修改的连锁风险。
+
+## 验证
+
+- 8 项标准 SwiftPM 测试。
+- 63 项完整兼容性和真实文件事务回归。
+- 严格 Debug/Release 构建,编译警告按错误处理。
+- 可重复的 64/500/3,000 项扫描和 1,000 次增量日志基线,详见 `PERFORMANCE_2.5.md`。
+
+## 安装说明
+
+1. 解压 `空间文件夹-v2.5.0.zip`。
+2. 将 `空间文件夹.app` 拖入“应用程序”。
+3. 首次启动如被 Gatekeeper 阻止,请右键 App 选择“打开”。
+4. 首次打开原 2.4 空间时,布局和历史会自动迁移,不会移动用户文件。
+
+当前发布包使用 ad-hoc 本地签名,尚未使用 Developer ID 公证。高强度使用时仍应对重要真实文件保持常规备份。
diff --git a/Sources/SpatialFolder/CanvasLayoutEngine.swift b/Sources/SpatialFolder/CanvasLayoutEngine.swift
new file mode 100644
index 0000000..7430888
--- /dev/null
+++ b/Sources/SpatialFolder/CanvasLayoutEngine.swift
@@ -0,0 +1,185 @@
+import Foundation
+
+struct CanvasLayoutItem: Equatable, Sendable {
+ var id: String
+ var scale: CGFloat
+}
+
+struct CanvasPlacementResult: Equatable, Sendable {
+ var positions: [String: CanvasPoint]
+ var inboxIDs: Set
+ var placedIDs: [String]
+}
+
+/// 纯坐标引擎:不访问磁盘、不发布 UI 状态。主模型只负责把输入快照交给它并应用结果。
+struct CanvasLayoutEngine: Sendable {
+ let capacity: Int
+ let columns: Int
+ let grid: CGFloat
+
+ init(capacity: Int = 64, columns: Int = 8, grid: CGFloat = 24) {
+ self.capacity = capacity
+ self.columns = columns
+ self.grid = grid
+ }
+
+ func initialLayout(for items: [CanvasLayoutItem], canvasSize: CGSize) -> CanvasPlacementResult {
+ var positions: [String: CanvasPoint] = [:]
+ var inbox = Set()
+ for (index, item) in items.enumerated() {
+ if index < capacity {
+ positions[item.id] = gridPoint(for: index, scale: item.scale, canvasSize: canvasSize)
+ } else {
+ inbox.insert(item.id)
+ }
+ }
+ return CanvasPlacementResult(
+ positions: positions,
+ inboxIDs: inbox,
+ placedIDs: Array(items.prefix(capacity).map(\.id))
+ )
+ }
+
+ /// 新项目使用第一个空网格;已有项目的位置不会参与重排。
+ func assignNewItems(
+ _ items: [CanvasLayoutItem],
+ positions: [String: CanvasPoint],
+ inboxIDs: Set,
+ canvasSize: CGSize
+ ) -> CanvasPlacementResult {
+ var resultPositions = positions
+ var resultInbox = inboxIDs
+ var active = items.filter { !resultInbox.contains($0.id) && resultPositions[$0.id] != nil }
+ var placed: [String] = []
+ for item in items where resultPositions[item.id] == nil && !resultInbox.contains(item.id) {
+ if active.count < capacity,
+ let point = nextAvailableGridPoint(
+ for: item,
+ activeItems: active,
+ positions: resultPositions,
+ canvasSize: canvasSize
+ ) {
+ resultPositions[item.id] = point
+ active.append(item)
+ placed.append(item.id)
+ } else {
+ resultInbox.insert(item.id)
+ }
+ }
+ return CanvasPlacementResult(positions: resultPositions, inboxIDs: resultInbox, placedIDs: placed)
+ }
+
+ /// 外部拖入从鼠标落点附近开始寻找最近空网格。多个项目按“由近到远、由上到下、由左到右”
+ /// 稳定排列;没有空间时只把溢出项目送入待放置区,不挪动任何已有图标。
+ func placeImportedItems(
+ _ importedItems: [CanvasLayoutItem],
+ near requestedPoint: CGPoint,
+ existingItems: [CanvasLayoutItem],
+ positions: [String: CanvasPoint],
+ inboxIDs: Set,
+ canvasSize: CGSize
+ ) -> CanvasPlacementResult {
+ let importedIDs = Set(importedItems.map(\.id))
+ var resultPositions = positions.filter { !importedIDs.contains($0.key) }
+ var resultInbox = inboxIDs.subtracting(importedIDs)
+ var occupied = existingItems
+ .filter { !importedIDs.contains($0.id) && !resultInbox.contains($0.id) }
+ .compactMap { item -> CGRect? in
+ guard let point = resultPositions[item.id] else { return nil }
+ return iconRect(at: point, scale: item.scale)
+ }
+ var placed: [String] = []
+
+ for item in importedItems {
+ let candidates = (0.. CanvasPoint {
+ let halfWidth = max(grid, 52 * scale)
+ let halfHeight = max(grid, 48 * scale)
+ let maximumX = max(halfWidth, canvasSize.width - halfWidth)
+ let maximumY = max(halfHeight, canvasSize.height - halfHeight)
+ let snappedX = (point.x / grid).rounded() * grid
+ let snappedY = (point.y / grid).rounded() * grid
+ return CanvasPoint(
+ x: min(maximumX, max(halfWidth, snappedX)),
+ y: min(maximumY, max(halfHeight, snappedY))
+ )
+ }
+
+ func gridPoint(for index: Int, scale: CGFloat, canvasSize: CGSize) -> CanvasPoint {
+ let column = index % columns
+ let row = index / columns
+ let cellWidth = canvasSize.width / CGFloat(columns)
+ let cellHeight = canvasSize.height / CGFloat(columns)
+ return snapped(
+ CGPoint(
+ x: cellWidth * (CGFloat(column) + 0.5),
+ y: cellHeight * (CGFloat(row) + 0.5)
+ ),
+ scale: scale,
+ canvasSize: canvasSize
+ )
+ }
+
+ func iconRect(at point: CanvasPoint, scale: CGFloat) -> CGRect {
+ let halfWidth = 52 * scale
+ let halfHeight = 48 * scale
+ return CGRect(
+ x: point.x - halfWidth,
+ y: point.y - halfHeight,
+ width: halfWidth * 2,
+ height: halfHeight * 2
+ )
+ }
+
+ func isInsideBounds(_ point: CanvasPoint, scale: CGFloat, canvasSize: CGSize) -> Bool {
+ let rect = iconRect(at: point, scale: scale)
+ return rect.minX >= 0 && rect.minY >= 0 && rect.maxX <= canvasSize.width && rect.maxY <= canvasSize.height
+ }
+
+ private func nextAvailableGridPoint(
+ for item: CanvasLayoutItem,
+ activeItems: [CanvasLayoutItem],
+ positions: [String: CanvasPoint],
+ canvasSize: CGSize
+ ) -> CanvasPoint? {
+ let occupied = activeItems.compactMap { active -> CGRect? in
+ guard let point = positions[active.id] else { return nil }
+ return iconRect(at: point, scale: active.scale)
+ }
+ return (0.. CGFloat {
+ let dx = point.x - requested.x
+ let dy = point.y - requested.y
+ return dx * dx + dy * dy
+ }
+}
diff --git a/Sources/SpatialFolder/CanvasSessionLock.swift b/Sources/SpatialFolder/CanvasSessionLock.swift
new file mode 100644
index 0000000..6888157
--- /dev/null
+++ b/Sources/SpatialFolder/CanvasSessionLock.swift
@@ -0,0 +1,117 @@
+import Darwin
+import Foundation
+
+/// 描述当前持有画布写入锁的进程,供第二个 App 给出可理解的提示。
+struct CanvasSessionOwner: Codable, Equatable, Sendable {
+ let processID: Int32
+ let appVersion: String
+ let acquiredAt: Date
+}
+
+/// 尝试获取画布锁后的结果。
+enum CanvasSessionLockResult {
+ case acquired(CanvasSessionLock)
+ case occupied(CanvasSessionOwner?)
+}
+
+/// 使用 macOS 的 `flock` 为每张画布建立跨进程独占锁。
+///
+/// 锁与文件描述符生命周期绑定:App 正常释放、崩溃或被强制退出后,系统都会自动释放锁,
+/// 因此不会留下永久“死锁文件”。锁文件中的 JSON 只用于展示占用者信息,不承担锁语义。
+final class CanvasSessionLock {
+ private var fileDescriptor: Int32
+ let lockURL: URL
+ let owner: CanvasSessionOwner
+
+ private init(fileDescriptor: Int32, lockURL: URL, owner: CanvasSessionOwner) {
+ self.fileDescriptor = fileDescriptor
+ self.lockURL = lockURL
+ self.owner = owner
+ }
+
+ deinit {
+ release()
+ }
+
+ /// 默认把锁放在 Application Support,与布局文件分开保存。
+ static func defaultLockDirectory() -> URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ return base.appendingPathComponent("SpatialFolder/Locks", isDirectory: true)
+ }
+
+ /// 非阻塞地申请指定画布的写入权。
+ ///
+ /// - Returns: 成功时返回必须被会话持有的锁对象;失败时返回当前占用者元数据。
+ static func acquire(
+ canvasKey: String,
+ directory: URL = defaultLockDirectory(),
+ processID: Int32 = getpid(),
+ appVersion: String
+ ) throws -> CanvasSessionLockResult {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let lockURL = directory.appendingPathComponent(canvasKey).appendingPathExtension("lock")
+ let descriptor = Darwin.open(lockURL.path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR)
+ guard descriptor >= 0 else { throw posixError() }
+
+ guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
+ let lockError = errno
+ Darwin.close(descriptor)
+ if lockError == EWOULDBLOCK || lockError == EAGAIN {
+ return .occupied(readOwner(from: lockURL))
+ }
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(lockError))
+ }
+
+ let owner = CanvasSessionOwner(
+ processID: processID,
+ appVersion: appVersion,
+ acquiredAt: Date()
+ )
+ do {
+ try writeOwner(owner, to: descriptor)
+ return .acquired(CanvasSessionLock(
+ fileDescriptor: descriptor,
+ lockURL: lockURL,
+ owner: owner
+ ))
+ } catch {
+ flock(descriptor, LOCK_UN)
+ Darwin.close(descriptor)
+ throw error
+ }
+ }
+
+ /// 主动释放锁;重复调用是安全的。
+ func release() {
+ guard fileDescriptor >= 0 else { return }
+ flock(fileDescriptor, LOCK_UN)
+ Darwin.close(fileDescriptor)
+ fileDescriptor = -1
+ }
+
+ private static func writeOwner(_ owner: CanvasSessionOwner, to descriptor: Int32) throws {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(owner)
+ guard ftruncate(descriptor, 0) == 0, lseek(descriptor, 0, SEEK_SET) >= 0 else {
+ throw posixError()
+ }
+ let written = data.withUnsafeBytes { buffer -> Int in
+ guard let address = buffer.baseAddress else { return 0 }
+ return Darwin.write(descriptor, address, buffer.count)
+ }
+ guard written == data.count else { throw posixError() }
+ _ = fsync(descriptor)
+ }
+
+ private static func readOwner(from url: URL) -> CanvasSessionOwner? {
+ guard let data = try? Data(contentsOf: url) else { return nil }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try? decoder.decode(CanvasSessionOwner.self, from: data)
+ }
+
+ private static func posixError() -> NSError {
+ NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+}
diff --git a/Sources/SpatialFolder/CanvasViewport.swift b/Sources/SpatialFolder/CanvasViewport.swift
index 141a733..339dc67 100644
--- a/Sources/SpatialFolder/CanvasViewport.swift
+++ b/Sources/SpatialFolder/CanvasViewport.swift
@@ -17,4 +17,20 @@ enum CanvasViewport {
displaySize.height / logicalSize.height
)
}
+
+ static func presentationSize(
+ logicalSize: CGSize,
+ viewportSize: CGSize,
+ displayScale: CGFloat
+ ) -> CGSize {
+ guard logicalSize.width > 0, logicalSize.height > 0,
+ viewportSize.width > 0, viewportSize.height > 0,
+ displayScale > 0 else {
+ return logicalSize
+ }
+ return CGSize(
+ width: max(logicalSize.width, viewportSize.width / displayScale),
+ height: max(logicalSize.height, viewportSize.height / displayScale)
+ )
+ }
}
diff --git a/Sources/SpatialFolder/ContentView.swift b/Sources/SpatialFolder/ContentView.swift
index 28e25a2..5924e6d 100644
--- a/Sources/SpatialFolder/ContentView.swift
+++ b/Sources/SpatialFolder/ContentView.swift
@@ -5,6 +5,9 @@ struct ContentView: View {
@EnvironmentObject private var model: FolderCanvasModel
@State private var resetConfirmationPresented = false
@State private var referenceCanvasConfirmationPresented = false
+ @State private var historyPresented = false
+ @State private var inboxPresented = false
+ @State private var toolbarHeight: CGFloat = 52
var body: some View {
VStack(spacing: 0) {
@@ -43,9 +46,34 @@ struct ContentView: View {
.alert("空间文件夹", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) {
Button("好", role: .cancel) { model.errorMessage = nil }
} message: { Text(model.errorMessage ?? "") }
+ .alert(
+ model.pendingConflict?.title ?? "发现同名项目",
+ isPresented: Binding(
+ get: { model.pendingConflict != nil },
+ set: { if !$0, model.pendingConflict != nil { model.resolvePendingConflict(.cancel) } }
+ )
+ ) {
+ Button("保留两者") { model.resolvePendingConflict(.keepBoth) }
+ Button("替换", role: .destructive) { model.resolvePendingConflict(.replace) }
+ Button("取消", role: .cancel) { model.resolvePendingConflict(.cancel) }
+ } message: {
+ Text(model.pendingConflict?.message ?? "")
+ }
.sheet(item: $model.infoItem) { item in
FileInfoView(item: item)
}
+ .sheet(isPresented: $historyPresented) {
+ OperationHistoryView()
+ .environmentObject(model)
+ }
+ .sheet(isPresented: $inboxPresented) {
+ InboxPanelView()
+ .environmentObject(model)
+ }
+ .sheet(isPresented: $model.isRecoveryWizardPresented) {
+ RecoveryWizardView()
+ .environmentObject(model)
+ }
.alert("重置当前布局?", isPresented: $resetConfirmationPresented) {
Button("取消", role: .cancel) {}
Button("重置", role: .destructive, action: model.resetLayout)
@@ -58,11 +86,27 @@ struct ContentView: View {
} message: {
Text("仅当旧版布局已经被小屏幕压缩时使用。文件不会移动,图标坐标会按当前显示器重新映射;操作可撤销并会自动备份。")
}
+ .background {
+ WindowAspectRatioController(
+ canvasSize: model.desktopCanvasSize,
+ fixedChromeHeight: toolbarHeight + 1
+ )
+ }
+ .onPreferenceChange(ToolbarHeightPreferenceKey.self) { height in
+ if height > 0 { toolbarHeight = height }
+ }
+ .overlay(alignment: .bottom) {
+ if let progress = model.fileOperationProgress {
+ fileOperationProgressView(progress)
+ .padding(.bottom, 14)
+ }
+ }
}
private var toolbar: some View {
HStack(spacing: 12) {
Button(action: model.chooseFolder) { Label("选择文件夹", systemImage: "folder") }
+ .disabled(model.isFileOperationInProgress)
if let folder = model.folderURL {
Text(folder.lastPathComponent)
.font(.headline)
@@ -75,12 +119,13 @@ struct ContentView: View {
.help(recent.path)
}
} label: { Label("最近空间", systemImage: "clock.arrow.circlepath") }
- .disabled(model.recentFolders.isEmpty)
+ .disabled(model.recentFolders.isEmpty || model.isFileOperationInProgress)
Spacer()
TextField("筛选当前空间", text: $model.searchText)
.textFieldStyle(.roundedBorder)
.frame(width: 180)
Button(action: model.createFolder) { Label("新建文件夹", systemImage: "folder.badge.plus") }
+ .disabled(model.sessionIsReadOnly || model.isFileOperationInProgress || model.isLoadingOperationHistory)
Menu {
Button("选择图片…", action: model.chooseWallpaper)
Button("使用系统桌面壁纸") { model.setWallpaper(nil) }
@@ -88,9 +133,9 @@ struct ContentView: View {
Menu {
Button(model.isLocked ? "解锁画布" : "锁定画布", action: model.toggleLocked)
Divider()
- Button("撤销布局修改", action: model.undoLayoutChange)
+ Button("撤销上一步操作", action: model.undoLastAction)
.disabled(!model.canUndo)
- Button("重做布局修改", action: model.redoLayoutChange)
+ Button("重做上一步操作", action: model.redoLastAction)
.disabled(!model.canRedo)
Button("找回越界项目", action: model.recoverOutOfBoundsItems)
.disabled(model.isLocked)
@@ -109,17 +154,26 @@ struct ContentView: View {
} label: {
Label(model.isLocked ? "已锁定" : "布局", systemImage: model.isLocked ? "lock.fill" : "square.grid.3x3")
}
+ Button { historyPresented = true } label: {
+ if model.isLoadingOperationHistory {
+ ProgressView().controlSize(.small)
+ } else {
+ Label("最近操作", systemImage: model.operationHistoryIsBlocked ? "exclamationmark.triangle.fill" : "clock.arrow.circlepath")
+ }
+ }
+ .disabled(model.isLoadingOperationHistory)
+ .help(model.operationHistoryIsBlocked ? "操作记录需要修复" : "查看、撤销或重做最近操作")
+ if !model.recoveryCases.isEmpty {
+ Button { model.isRecoveryWizardPresented = true } label: {
+ Label("待恢复 \(model.recoveryCases.count)", systemImage: "cross.case.fill")
+ }
+ .help("核对上次未确认完成的文件操作")
+ }
if !model.inboxItems.isEmpty {
- Menu {
- Text("先把主画布项目移入待放置区,可腾出位置。")
- Divider()
- ForEach(model.inboxItems) { item in
- Button(item.name) { model.placeFromInbox(item) }
- .disabled(model.isLocked)
- }
- } label: {
+ Button { inboxPresented = true } label: {
Label("待放置 \(model.inboxItems.count)", systemImage: "tray.full")
}
+ .help("搜索、多选并放回主画布")
}
Menu {
Button("跟随系统") { model.setAppearanceMode("system") }
@@ -128,10 +182,211 @@ struct ContentView: View {
} label: { Label("外观", systemImage: "circle.lefthalf.filled") }
Button(action: model.refreshItems) { Image(systemName: "arrow.clockwise") }
.help("刷新")
+ .overlay {
+ if model.isRefreshing { ProgressView().controlSize(.small) }
+ }
} else { Spacer() }
}
.padding(12)
.background(.bar)
+ .background {
+ GeometryReader { geometry in
+ Color.clear.preference(
+ key: ToolbarHeightPreferenceKey.self,
+ value: geometry.size.height
+ )
+ }
+ }
+ }
+
+ /// 长文件操作的非阻塞进度条;取消会等待当前系统调用结束后回滚。
+ private func fileOperationProgressView(_ progress: FileOperationProgressState) -> some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(progress.title).font(.callout.weight(.semibold))
+ Text(progress.detail).font(.caption).foregroundStyle(.secondary)
+ if let fraction = progress.fractionCompleted {
+ ProgressView(value: fraction)
+ .frame(width: 280)
+ } else {
+ ProgressView().frame(width: 280)
+ }
+ }
+ if progress.allowsCancellation {
+ Button(progress.isCancelling ? "正在取消…" : "取消") {
+ model.cancelCurrentFileOperation()
+ }
+ .disabled(progress.isCancelling)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .shadow(radius: 8)
+ }
+}
+
+private struct ToolbarHeightPreferenceKey: PreferenceKey {
+ static let defaultValue: CGFloat = 52
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = nextValue()
+ }
+}
+
+private struct OperationHistoryView: View {
+ @EnvironmentObject private var model: FolderCanvasModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("最近操作").font(.title2.weight(.semibold))
+ Text("文件操作记录会跨重启保留;布局撤销快照仅在本次运行期间有效。")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("完成") { dismiss() }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ Divider()
+
+ if model.operationHistoryIsBlocked {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
+ Text("操作记录无法读取。原记录已保留,为避免无法恢复的文件变更,真实文件修改已暂停。")
+ .font(.callout)
+ Spacer()
+ VStack(alignment: .trailing) {
+ Button("显示记录位置", action: model.revealOperationHistory)
+ Button("存档损坏记录并继续…", action: model.archiveDamagedOperationHistoryAndContinue)
+ }
+ }
+ .padding()
+ .background(Color.yellow.opacity(0.12))
+ }
+
+ List(Array(model.operationRecords.reversed())) { record in
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: record.category == .layout ? "square.grid.3x3" : "doc.badge.gearshape")
+ .frame(width: 20)
+ .foregroundStyle(record.state == .failed || record.state == .unavailable ? .orange : .secondary)
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(record.summary).fontWeight(.medium)
+ Text(record.state.title)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(.quaternary, in: Capsule())
+ }
+ Text("\(record.kind.title) · \(record.transitionDate.formatted(date: .abbreviated, time: .standard))")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let detail = record.detail {
+ Text(detail).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ }
+ .padding(.vertical, 4)
+ }
+ .overlay {
+ if model.operationRecords.isEmpty {
+ ContentUnavailableView("还没有操作记录", systemImage: "clock")
+ }
+ }
+
+ Divider()
+ HStack {
+ Button("撤销", action: model.undoLastAction).disabled(!model.canUndo)
+ Button("重做", action: model.redoLastAction).disabled(!model.canRedo)
+ if !model.recoveryCases.isEmpty {
+ Button("处理异常操作(\(model.recoveryCases.count))") {
+ dismiss()
+ model.isRecoveryWizardPresented = true
+ }
+ }
+ Spacer()
+ Button("导出诊断信息…", action: model.exportDiagnostics)
+ }
+ .padding()
+ }
+ .frame(minWidth: 720, minHeight: 480)
+ }
+}
+
+private struct RecoveryWizardView: View {
+ @EnvironmentObject private var model: FolderCanvasModel
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("异常恢复向导").font(.title2.weight(.semibold))
+ Text("向导只读取磁盘证据并修正操作记录,不会移动、覆盖或删除任何文件。")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("在访达中核对", action: model.revealRecoveryFolder)
+ Button("完成") { dismiss() }.keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ Divider()
+
+ List {
+ ForEach(model.recoveryCases, id: \.recordID) { recoveryCase in
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text(recoveryCase.summary).font(.headline)
+ Text(recoveryCase.currentState.title)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(.quaternary, in: Capsule())
+ Spacer()
+ Label(
+ recoveryCase.suggestedOutcome.title,
+ systemImage: recoveryCase.suggestedOutcome == .manualReview
+ ? "exclamationmark.triangle.fill"
+ : "checkmark.shield.fill"
+ )
+ .font(.caption.weight(.medium))
+ .foregroundStyle(recoveryCase.suggestedOutcome == .manualReview ? .orange : .green)
+ }
+ Text(recoveryCase.explanation).font(.callout)
+ ForEach(recoveryCase.evidence, id: \.id) { evidence in
+ let isSupported = evidence.supportsApplied || evidence.supportsUndone
+ HStack(alignment: .firstTextBaseline) {
+ Image(systemName: isSupported ? "checkmark.circle" : "questionmark.circle")
+ .foregroundStyle(isSupported ? Color.secondary : Color.orange)
+ Text(evidence.itemName).lineLimit(1)
+ Spacer()
+ Text(evidence.observation).foregroundStyle(.secondary)
+ }
+ .font(.caption)
+ }
+ HStack {
+ if recoveryCase.suggestedOutcome == .applied {
+ Button("确认已完成") { model.resolveRecovery(recoveryCase, as: .applied) }
+ .buttonStyle(.borderedProminent)
+ }
+ if recoveryCase.suggestedOutcome == .undone {
+ Button("确认已回滚") { model.resolveRecovery(recoveryCase, as: .undone) }
+ .buttonStyle(.borderedProminent)
+ }
+ Button("仅存档记录") { model.resolveRecovery(recoveryCase, as: .archived) }
+ }
+ }
+ .padding(.vertical, 8)
+ }
+ }
+ }
+ .frame(minWidth: 760, minHeight: 520)
}
}
@@ -145,10 +400,21 @@ private struct FolderCanvasView: View {
viewportWidth: geometry.size.width,
displaySize: model.currentDisplaySize
)
+ let presentationSize = CanvasViewport.presentationSize(
+ logicalSize: model.desktopCanvasSize,
+ viewportSize: geometry.size,
+ displayScale: displayScale
+ )
ScrollView(.vertical) {
ZStack(alignment: .topLeading) {
- CanvasBackground(url: model.wallpaperURL ?? model.defaultDesktopWallpaperURL)
- .frame(width: model.desktopCanvasSize.width, height: model.desktopCanvasSize.height)
+ CanvasBackground(
+ url: model.wallpaperURL ?? model.defaultDesktopWallpaperURL,
+ requestedPixelSize: max(
+ presentationSize.width * displayScale,
+ presentationSize.height * displayScale
+ ) * 2
+ )
+ .frame(width: presentationSize.width, height: presentationSize.height)
.contentShape(Rectangle())
.onTapGesture { model.clearSelection() }
.gesture(selectionGesture)
@@ -164,13 +430,18 @@ private struct FolderCanvasView: View {
.allowsHitTesting(false)
}
}
- .frame(width: model.desktopCanvasSize.width,
- height: model.desktopCanvasSize.height)
+ .frame(width: presentationSize.width,
+ height: presentationSize.height)
.scaleEffect(displayScale, anchor: .topLeading)
- .frame(width: model.desktopCanvasSize.width * displayScale,
- height: model.desktopCanvasSize.height * displayScale,
+ .frame(width: presentationSize.width * displayScale,
+ height: presentationSize.height * displayScale,
alignment: .topLeading)
- .onDrop(of: [UTType.fileURL], isTargeted: nil, perform: receiveDroppedFiles)
+ .onDrop(
+ of: [UTType.fileURL],
+ delegate: CanvasFileDropDelegate(displayScale: displayScale) { providers, logicalPoint in
+ receiveDroppedFiles(providers, at: logicalPoint)
+ }
+ )
.contextMenu {
Menu("新建") {
Button("文件夹", action: model.createFolder)
@@ -183,8 +454,11 @@ private struct FolderCanvasView: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.overlay(alignment: .topTrailing) {
- if model.isLocked {
- Label("布局已锁定", systemImage: "lock.fill")
+ if model.sessionIsReadOnly || model.isLocked {
+ Label(
+ model.sessionIsReadOnly ? "另一进程占用 · 只读" : "布局已锁定",
+ systemImage: model.sessionIsReadOnly ? "exclamationmark.lock.fill" : "lock.fill"
+ )
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 6)
@@ -202,11 +476,17 @@ private struct FolderCanvasView: View {
}
}
- private func receiveDroppedFiles(_ providers: [NSItemProvider]) -> Bool {
- for provider in providers {
+ private func receiveDroppedFiles(_ providers: [NSItemProvider], at logicalPoint: CGPoint) -> Bool {
+ guard !providers.isEmpty else { return false }
+ // NSItemProvider 会在任意线程、以任意顺序回调。先按原始序号收齐 URL,
+ // 再一次性交给模型,保证一次拖入只有一条批量记录和一个冲突决策。
+ let collector = DroppedURLCollector(count: providers.count) { urls in
+ model.importFiles(urls, dropPoint: logicalPoint)
+ }
+ for (index, provider) in providers.enumerated() {
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
let url = (item as? URL) ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
- if let url { DispatchQueue.main.async { model.importFiles([url]) } }
+ collector.resolve(index: index, url: url)
}
}
return true
@@ -229,11 +509,63 @@ private struct FolderCanvasView: View {
}
}
+/// SwiftUI 的简化 onDrop 回调不提供鼠标坐标;DropDelegate 会保留投放位置,并把视图坐标
+/// 还原为逻辑画布坐标,保证不同显示器缩放下落点一致。
+private struct CanvasFileDropDelegate: DropDelegate {
+ let displayScale: CGFloat
+ let completion: ([NSItemProvider], CGPoint) -> Bool
+
+ func validateDrop(info: DropInfo) -> Bool {
+ info.hasItemsConforming(to: [UTType.fileURL])
+ }
+
+ func performDrop(info: DropInfo) -> Bool {
+ let scale = max(0.01, displayScale)
+ let logicalPoint = CGPoint(x: info.location.x / scale, y: info.location.y / scale)
+ return completion(info.itemProviders(for: [UTType.fileURL]), logicalPoint)
+ }
+}
+
+/// 把一次外部拖放产生的异步回调合并成一个有序批次。
+/// 这里使用锁只保护很短的内存写入,不做文件 I/O,因此不会阻塞画布主线程。
+private final class DroppedURLCollector: @unchecked Sendable {
+ private let lock = NSLock()
+ private var urls: [URL?]
+ private var resolvedIndexes: Set = []
+ private var remaining: Int
+ private let completion: @MainActor ([URL]) -> Void
+
+ init(count: Int, completion: @escaping @MainActor ([URL]) -> Void) {
+ urls = Array(repeating: nil, count: count)
+ remaining = count
+ self.completion = completion
+ }
+
+ func resolve(index: Int, url: URL?) {
+ lock.lock()
+ guard urls.indices.contains(index), !resolvedIndexes.contains(index) else {
+ lock.unlock()
+ return
+ }
+ resolvedIndexes.insert(index)
+ urls[index] = url
+ remaining -= 1
+ let completedURLs = remaining == 0 ? urls.compactMap { $0 } : nil
+ lock.unlock()
+
+ guard let completedURLs else { return }
+ Task { @MainActor [completion] in completion(completedURLs) }
+ }
+}
+
private struct CanvasBackground: View {
let url: URL?
+ let requestedPixelSize: CGFloat
+ @State private var image: NSImage?
+
var body: some View {
Group {
- if let url, let image = NSImage(contentsOf: url) {
+ if let image {
Image(nsImage: image).resizable().scaledToFill()
} else {
LinearGradient(colors: [Color(red: 0.08, green: 0.14, blue: 0.23), Color(red: 0.12, green: 0.28, blue: 0.32)], startPoint: .topLeading, endPoint: .bottomTrailing)
@@ -241,6 +573,19 @@ private struct CanvasBackground: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
+ .task(id: request) {
+ guard let request else {
+ image = nil
+ return
+ }
+ let decoded = await WallpaperImageLoader.shared.image(for: request)
+ guard !Task.isCancelled else { return }
+ image = decoded.map { NSImage(cgImage: $0, size: .zero) }
+ }
+ }
+
+ private var request: WallpaperImageRequest? {
+ url.map { WallpaperImageRequest(url: $0, requestedPixelSize: requestedPixelSize) }
}
}
@@ -255,9 +600,10 @@ private struct CanvasIcon: View {
let point = model.position(for: item)
let scale = model.scale(for: item)
let isSelected = model.selectedIDs.contains(item.id)
+ let contextItems = model.contextItems(for: item)
let sharedOffset = model.draggingIDs.contains(item.id) ? model.dragTranslation : .zero
VStack(spacing: 5) {
- Image(nsImage: item.icon).resizable().interpolation(.high)
+ Image(nsImage: model.icon(for: item)).resizable().interpolation(.high)
.frame(width: 56 * scale, height: 56 * scale)
Text(item.name)
.font(.system(size: max(10, 12 * scale)))
@@ -299,21 +645,23 @@ private struct CanvasIcon: View {
})
.onDrag { NSItemProvider(object: item.url as NSURL) }
.contextMenu {
- Button("打开") { model.open(item) }
- Button("在访达中显示") { model.reveal(item) }
+ Button(contextItems.count == 1 ? "打开" : "打开 \(contextItems.count) 个项目") {
+ model.open(contextItems)
+ }
+ Button("在访达中显示") { model.reveal(contextItems) }
Divider()
- Button("复制") { model.copy([item]) }
- Button("剪切") { model.cut([item]) }
- Button("制作副本") { model.duplicate(item) }
- Button("压缩") { model.compress(item) }
- Button("分享…") { model.share(item) }
+ Button("复制") { model.copy(contextItems) }
+ Button("剪切") { model.cut(contextItems) }
+ Button("制作副本") { model.duplicate(contextItems) }
+ Button("压缩") { model.compress(contextItems) }
+ Button("分享…") { model.share(contextItems) }
Divider()
Menu("标签") {
ForEach(FinderTag.allCases) { tag in
Button {
- model.toggleTag(tag.encodedValue, for: item)
+ model.toggleTag(tag.encodedValue, for: contextItems)
} label: {
- if model.hasTag(tag.encodedValue, in: item) {
+ if contextItems.allSatisfy({ model.hasTag(tag.encodedValue, in: $0) }) {
Label(tag.title, systemImage: "checkmark")
} else {
Text(tag.title)
@@ -321,23 +669,25 @@ private struct CanvasIcon: View {
}
}
Divider()
- Button("清除所有标签") { model.clearTags(for: item) }
- .disabled(item.tags.isEmpty)
+ Button("清除所有标签") { model.clearTags(for: contextItems) }
+ .disabled(contextItems.allSatisfy { $0.tags.isEmpty })
}
Divider()
Menu("图标与字体大小") {
- scaleButton("小", scale: 0.75, currentScale: scale)
- scaleButton("标准", scale: 1, currentScale: scale)
- scaleButton("大", scale: 1.25, currentScale: scale)
- scaleButton("特大", scale: 1.5, currentScale: scale)
+ scaleButton("小", scale: 0.75, currentScale: scale, items: contextItems)
+ scaleButton("标准", scale: 1, currentScale: scale, items: contextItems)
+ scaleButton("大", scale: 1.25, currentScale: scale, items: contextItems)
+ scaleButton("特大", scale: 1.5, currentScale: scale, items: contextItems)
}
.disabled(model.isLocked)
- Button("移到待放置区") { model.moveToInbox(item) }
+ Button("移到待放置区") { model.moveToInbox(contextItems) }
.disabled(model.isLocked)
Divider()
- Button("显示简介") { model.showInfo(item) }
- Button("重命名…") { renameText = item.name; renamePresented = true }
- Button("移至废纸篓", role: .destructive) { model.trash(item) }
+ if contextItems.count == 1 {
+ Button("显示简介") { model.showInfo(item) }
+ Button("重命名…") { renameText = item.name; renamePresented = true }
+ }
+ Button("移至废纸篓", role: .destructive) { model.trash(contextItems) }
}
.alert("重命名", isPresented: $renamePresented) {
TextField("名称", text: $renameText)
@@ -347,9 +697,14 @@ private struct CanvasIcon: View {
}
@ViewBuilder
- private func scaleButton(_ title: String, scale: CGFloat, currentScale: CGFloat) -> some View {
+ private func scaleButton(
+ _ title: String,
+ scale: CGFloat,
+ currentScale: CGFloat,
+ items: [FolderItem]
+ ) -> some View {
Button {
- model.setScale(scale, for: item)
+ model.setScale(scale, for: items)
} label: {
if abs(currentScale - scale) < 0.01 {
Label(title, systemImage: "checkmark")
@@ -372,6 +727,92 @@ private struct CanvasIcon: View {
}
}
+/// 待放置区不再塞进工具栏菜单:文件多时可搜索、全选和批量放回。
+private struct InboxPanelView: View {
+ @EnvironmentObject private var model: FolderCanvasModel
+ @Environment(\.dismiss) private var dismiss
+ @State private var query = ""
+ @State private var selectedIDs: Set = []
+
+ private var filteredItems: [FolderItem] {
+ let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedQuery.isEmpty else { return model.inboxItems }
+ return model.inboxItems.filter {
+ $0.name.localizedCaseInsensitiveContains(normalizedQuery)
+ }
+ }
+
+ private var selectedItems: [FolderItem] {
+ model.inboxItems.filter { selectedIDs.contains($0.id) }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("待放置区").font(.title2.bold())
+ Text("主画布还可放置 \(model.availableCanvasSlots) 个项目")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Button("完成") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding()
+
+ Divider()
+
+ if model.inboxItems.isEmpty {
+ ContentUnavailableView("待放置区为空", systemImage: "tray")
+ } else {
+ List(selection: $selectedIDs) {
+ ForEach(filteredItems) { item in
+ HStack(spacing: 10) {
+ Image(nsImage: model.icon(for: item))
+ .resizable()
+ .frame(width: 28, height: 28)
+ Text(item.name).lineLimit(1)
+ Spacer()
+ }
+ .tag(item.id)
+ }
+ }
+ .searchable(text: $query, prompt: "搜索待放置项目")
+ }
+
+ Divider()
+
+ HStack {
+ Button("全选当前结果") {
+ selectedIDs.formUnion(filteredItems.map(\.id))
+ }
+ .disabled(filteredItems.isEmpty)
+ Button("清除选择") { selectedIDs.removeAll() }
+ .disabled(selectedIDs.isEmpty)
+ Spacer()
+ Text("已选 \(selectedItems.count) 项")
+ .foregroundStyle(.secondary)
+ Button("放回主画布") {
+ model.placeFromInbox(selectedItems)
+ selectedIDs.subtract(selectedItems.map(\.id))
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(
+ selectedItems.isEmpty
+ || model.availableCanvasSlots == 0
+ || !model.canEditLayout
+ )
+ }
+ .padding()
+ }
+ .frame(minWidth: 560, minHeight: 430)
+ .onChange(of: model.inboxIDs) { _, currentIDs in
+ selectedIDs.formIntersection(currentIDs)
+ }
+ }
+}
+
private enum FinderTag: CaseIterable, Identifiable {
case red, orange, yellow, green, blue, purple, gray
@@ -402,15 +843,13 @@ private enum FinderTag: CaseIterable, Identifiable {
private struct FileInfoView: View {
let item: FolderItem
+ @EnvironmentObject private var model: FolderCanvasModel
@Environment(\.dismiss) private var dismiss
var body: some View {
- let attributes = (try? FileManager.default.attributesOfItem(atPath: item.url.path)) ?? [:]
- let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0
- let modified = attributes[.modificationDate] as? Date
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
- Image(nsImage: item.icon).resizable().frame(width: 48, height: 48)
+ Image(nsImage: model.icon(for: item)).resizable().frame(width: 48, height: 48)
VStack(alignment: .leading) {
Text(item.name).font(.headline)
Text(item.url.pathExtension.isEmpty ? "文件夹" : item.url.pathExtension.uppercased())
@@ -419,8 +858,15 @@ private struct FileInfoView: View {
}
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) {
GridRow { Text("位置").foregroundStyle(.secondary); Text(item.url.deletingLastPathComponent().path).textSelection(.enabled) }
- GridRow { Text("大小").foregroundStyle(.secondary); Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) }
- if let modified {
+ GridRow {
+ Text("大小").foregroundStyle(.secondary)
+ if let snapshot = model.infoSnapshot {
+ Text(ByteCountFormatter.string(fromByteCount: snapshot.size, countStyle: .file))
+ } else {
+ ProgressView().controlSize(.small)
+ }
+ }
+ if let modified = model.infoSnapshot?.modificationDate {
GridRow { Text("修改日期").foregroundStyle(.secondary); Text(modified.formatted(date: .long, time: .shortened)) }
}
GridRow { Text("标签").foregroundStyle(.secondary); Text(item.tags.isEmpty ? "无" : item.tags.map { $0.components(separatedBy: "\n").first ?? $0 }.joined(separator: "、")) }
diff --git a/Sources/SpatialFolder/FileIconCache.swift b/Sources/SpatialFolder/FileIconCache.swift
new file mode 100644
index 0000000..38ee7bc
--- /dev/null
+++ b/Sources/SpatialFolder/FileIconCache.swift
@@ -0,0 +1,30 @@
+import AppKit
+import Foundation
+
+/// 主线程图标缓存。只有真正出现在画布或信息面板中的项目才会请求图标,
+/// 因而包含数千个文件的目录不会在首次扫描时一次性加载全部图标。
+@MainActor
+final class FileIconCache {
+ private let cache = NSCache()
+
+ init(countLimit: Int = 256) {
+ cache.countLimit = max(64, countLimit)
+ }
+
+ func icon(for url: URL) -> NSImage {
+ let key = url.path as NSString
+ if let cached = cache.object(forKey: key) { return cached }
+ let icon = NSWorkspace.shared.icon(forFile: url.path)
+ cache.setObject(icon, forKey: key)
+ return icon
+ }
+
+ /// 同一路径的文件被外部替换后,主动丢弃旧图标以免显示过期类型。
+ func invalidate(_ url: URL) {
+ cache.removeObject(forKey: url.path as NSString)
+ }
+
+ func removeAll() {
+ cache.removeAllObjects()
+ }
+}
diff --git a/Sources/SpatialFolder/FileOperationCoordinator.swift b/Sources/SpatialFolder/FileOperationCoordinator.swift
new file mode 100644
index 0000000..a9575ed
--- /dev/null
+++ b/Sources/SpatialFolder/FileOperationCoordinator.swift
@@ -0,0 +1,385 @@
+import Foundation
+
+/// 描述一次批量复制或移动中的单个项目计划。
+struct FileTransferPlan: Equatable, Sendable {
+ let source: URL
+ let destination: URL
+ let move: Bool
+ let replacesExistingDestination: Bool
+}
+
+/// 描述一次压缩的来源和预先占用的目标 ZIP,避免批量中途重新计算文件名。
+struct FileCompressionPlan: Equatable, Sendable {
+ let source: URL
+ let destination: URL
+}
+
+/// 协调器向主模型报告的细粒度事件。模型会在每一步完成后立即持久化事务记录。
+enum CoordinatedFileOperationEvent: Sendable {
+ case willBegin(step: Int, total: Int, detail: String)
+ case didApply(action: ReversibleFileAction, completed: Int, total: Int)
+ case rollingBack(detail: String)
+}
+
+/// 操作失败时同时携带已经完成的动作和回滚结果,不能只返回一条字符串。
+struct CoordinatedFileOperationFailure: LocalizedError, Sendable {
+ let message: String
+ let actions: [ReversibleFileAction]
+ let displacements: [ConflictDisplacement]
+ let rollbackSucceeded: Bool
+ let wasCancelled: Bool
+
+ var errorDescription: String? { message }
+}
+
+/// 串行执行耗时文件操作。Actor 隔离保证同一模型不会并行复制两批文件。
+actor FileOperationCoordinator {
+ typealias EventHandler = @Sendable (CoordinatedFileOperationEvent) async throws -> Void
+
+ private let fileManager: FileManager
+ private let fileOperationEngine: FileOperationEngine
+
+ init(fileOperationEngine: FileOperationEngine = FileOperationEngine()) {
+ self.fileOperationEngine = fileOperationEngine
+ fileManager = fileOperationEngine.fileManager
+ }
+
+ /// 执行一批复制/移动。取消不能强行打断 FileManager 正在进行的单文件复制,
+ /// 但会在该系统调用返回后立刻停止下一项,并安全回滚本批次已完成内容。
+ func performTransfers(
+ _ plans: [FileTransferPlan],
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ var actions: [ReversibleFileAction] = []
+ let totalSteps = plans.reduce(0) { $0 + ($1.replacesExistingDestination ? 2 : 1) }
+ var completed = 0
+
+ do {
+ for plan in plans {
+ try Task.checkCancellation()
+ if plan.replacesExistingDestination,
+ fileManager.fileExists(atPath: plan.destination.path) {
+ try await eventHandler(.willBegin(
+ step: completed + 1,
+ total: totalSteps,
+ detail: "正在保护同名项目“\(plan.destination.lastPathComponent)”"
+ ))
+ let trashURL = try fileOperationEngine.moveToTrash(plan.destination)
+ let action = ReversibleFileAction.discard(DiscardAction(
+ originalPath: plan.destination.path,
+ trashPath: trashURL.path
+ ))
+ actions.append(action)
+ completed += 1
+ try await eventHandler(.didApply(action: action, completed: completed, total: totalSteps))
+ }
+
+ try Task.checkCancellation()
+ let verb = plan.move ? "移动" : "复制"
+ try await eventHandler(.willBegin(
+ step: completed + 1,
+ total: totalSteps,
+ detail: "正在\(verb)“\(plan.source.lastPathComponent)”"
+ ))
+ do {
+ if plan.move {
+ try fileManager.moveItem(at: plan.source, to: plan.destination)
+ } else {
+ try fileManager.copyItem(at: plan.source, to: plan.destination)
+ }
+ } catch {
+ // 某些文件系统失败时会留下部分目标;把它登记为可回滚动作。
+ if fileManager.fileExists(atPath: plan.destination.path) {
+ let sourceStillExists = fileManager.fileExists(atPath: plan.source.path)
+ let partialAction: ReversibleFileAction = plan.move && !sourceStillExists
+ ? .relocate(RelocateAction(
+ originalPath: plan.source.path,
+ destinationPath: plan.destination.path
+ ))
+ : .materialize(MaterializeAction(destinationPath: plan.destination.path))
+ actions.append(partialAction)
+ completed += 1
+ try await eventHandler(.didApply(
+ action: partialAction,
+ completed: completed,
+ total: totalSteps
+ ))
+ }
+ throw error
+ }
+
+ let action: ReversibleFileAction = plan.move
+ ? .relocate(RelocateAction(
+ originalPath: plan.source.path,
+ destinationPath: plan.destination.path
+ ))
+ : .materialize(MaterializeAction(destinationPath: plan.destination.path))
+ actions.append(action)
+ completed += 1
+ try await eventHandler(.didApply(action: action, completed: completed, total: totalSteps))
+ }
+ try Task.checkCancellation()
+ return actions
+ } catch {
+ let cancelled = error is CancellationError
+ try? await eventHandler(.rollingBack(
+ detail: cancelled ? "正在取消并恢复本批次已完成内容" : "操作失败,正在恢复已完成内容"
+ ))
+ let rollback = rollback(actions: actions)
+ throw CoordinatedFileOperationFailure(
+ message: cancelled ? "操作已取消。" : error.localizedDescription,
+ actions: rollback.actions,
+ displacements: rollback.displacements,
+ rollbackSucceeded: rollback.succeeded,
+ wasCancelled: cancelled
+ )
+ }
+ }
+
+ /// 在后台执行系统 ditto 压缩;失败或取消后移除已产生的半成品压缩包。
+ func compress(
+ source: URL,
+ destination: URL,
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ try await performCompressions(
+ [FileCompressionPlan(source: source, destination: destination)],
+ eventHandler: eventHandler
+ )
+ }
+
+ func performCompressions(
+ _ plans: [FileCompressionPlan],
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ var actions: [ReversibleFileAction] = []
+ do {
+ for (index, plan) in plans.enumerated() {
+ try Task.checkCancellation()
+ try await eventHandler(.willBegin(
+ step: index + 1,
+ total: plans.count,
+ detail: "正在压缩“\(plan.source.lastPathComponent)”"
+ ))
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ process.arguments = ["-c", "-k", "--keepParent", plan.source.path, plan.destination.path]
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ let action = ReversibleFileAction.materialize(MaterializeAction(
+ destinationPath: plan.destination.path
+ ))
+ actions.append(action)
+ try await eventHandler(.didApply(
+ action: action,
+ completed: index + 1,
+ total: plans.count
+ ))
+ }
+ try Task.checkCancellation()
+ return actions
+ } catch {
+ // 失败的当前压缩也可能留下半成品,把尚未登记的目标补入回滚列表。
+ for plan in plans where
+ fileManager.fileExists(atPath: plan.destination.path) &&
+ !actions.contains(where: { action in
+ if case let .materialize(value) = action { return value.destinationPath == plan.destination.path }
+ return false
+ }) {
+ let partial = ReversibleFileAction.materialize(MaterializeAction(
+ destinationPath: plan.destination.path
+ ))
+ actions.append(partial)
+ try? await eventHandler(.didApply(
+ action: partial,
+ completed: actions.count,
+ total: plans.count
+ ))
+ }
+ try? await eventHandler(.rollingBack(detail: "正在清理本批次未完成的压缩包"))
+ let rollback = rollback(actions: actions)
+ throw CoordinatedFileOperationFailure(
+ message: error is CancellationError ? "压缩已取消。" : error.localizedDescription,
+ actions: rollback.actions,
+ displacements: rollback.displacements,
+ rollbackSucceeded: rollback.succeeded,
+ wasCancelled: error is CancellationError
+ )
+ }
+ }
+
+ /// 批量移至废纸篓。每个项目的真实废纸篓路径会逐项返回并立即写入事务记录。
+ func performTrash(
+ _ urls: [URL],
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ var actions: [ReversibleFileAction] = []
+ do {
+ for (index, url) in urls.enumerated() {
+ try Task.checkCancellation()
+ try await eventHandler(.willBegin(
+ step: index + 1,
+ total: urls.count,
+ detail: "正在将“\(url.lastPathComponent)”移至废纸篓"
+ ))
+ let trashURL = try fileOperationEngine.moveToTrash(url)
+ let action = ReversibleFileAction.discard(DiscardAction(
+ originalPath: url.path,
+ trashPath: trashURL.path
+ ))
+ actions.append(action)
+ try await eventHandler(.didApply(
+ action: action,
+ completed: index + 1,
+ total: urls.count
+ ))
+ }
+ try Task.checkCancellation()
+ return actions
+ } catch {
+ let cancelled = error is CancellationError
+ try? await eventHandler(.rollingBack(
+ detail: cancelled ? "正在取消并恢复废纸篓项目" : "删除未完成,正在恢复已移动项目"
+ ))
+ let rollback = rollback(actions: actions)
+ throw CoordinatedFileOperationFailure(
+ message: cancelled ? "移至废纸篓已取消。" : error.localizedDescription,
+ actions: rollback.actions,
+ displacements: rollback.displacements,
+ rollbackSucceeded: rollback.succeeded,
+ wasCancelled: cancelled
+ )
+ }
+ }
+
+ /// 创建单个真实目录也通过协调器执行,保证短 I/O 不会因为“通常很快”而回到主线程。
+ func createDirectory(
+ at destination: URL,
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ try Task.checkCancellation()
+ try await eventHandler(.willBegin(step: 1, total: 1, detail: "正在新建“\(destination.lastPathComponent)”"))
+ do {
+ try fileManager.createDirectory(at: destination, withIntermediateDirectories: false)
+ let action = ReversibleFileAction.materialize(MaterializeAction(destinationPath: destination.path))
+ try await eventHandler(.didApply(action: action, completed: 1, total: 1))
+ return [action]
+ } catch {
+ var actions: [ReversibleFileAction] = []
+ if fileManager.fileExists(atPath: destination.path) {
+ let partial = ReversibleFileAction.materialize(MaterializeAction(destinationPath: destination.path))
+ actions.append(partial)
+ try? await eventHandler(.didApply(action: partial, completed: 1, total: 1))
+ }
+ let rollback = rollback(actions: actions)
+ throw CoordinatedFileOperationFailure(
+ message: error.localizedDescription,
+ actions: rollback.actions,
+ displacements: rollback.displacements,
+ rollbackSucceeded: rollback.succeeded,
+ wasCancelled: error is CancellationError
+ )
+ }
+ }
+
+ /// Finder 标签逐项写入并逐项回报,日志失败会触发同批次回滚。
+ func applyTags(
+ _ actions: [TagAction],
+ eventHandler: EventHandler
+ ) async throws -> [ReversibleFileAction] {
+ var completedActions: [ReversibleFileAction] = []
+ do {
+ for (index, value) in actions.enumerated() {
+ try Task.checkCancellation()
+ try await eventHandler(.willBegin(
+ step: index + 1,
+ total: actions.count,
+ detail: "正在更新“\(URL(fileURLWithPath: value.path).lastPathComponent)”的标签"
+ ))
+ try writeFinderTags(value.after, to: URL(fileURLWithPath: value.path))
+ let action = ReversibleFileAction.tags(value)
+ completedActions.append(action)
+ try await eventHandler(.didApply(
+ action: action,
+ completed: index + 1,
+ total: actions.count
+ ))
+ }
+ return completedActions
+ } catch {
+ try? await eventHandler(.rollingBack(detail: "正在恢复本批次原有标签"))
+ let rollback = rollback(actions: completedActions)
+ throw CoordinatedFileOperationFailure(
+ message: error.localizedDescription,
+ actions: rollback.actions,
+ displacements: rollback.displacements,
+ rollbackSucceeded: rollback.succeeded,
+ wasCancelled: error is CancellationError
+ )
+ }
+ }
+
+ /// 把可能包含大量项目的撤销/重做放到后台 Actor;调用方会在启动前把记录标成过渡状态,
+ /// 因而即使 App 中途退出,下一次启动也不会把未知状态误报为成功。
+ func transition(
+ record: OperationRecord,
+ to targetState: OperationState,
+ conflictChoice: ConflictChoice,
+ eventHandler: EventHandler
+ ) async throws -> OperationRecord {
+ let verb = targetState == .undone ? "撤销" : "重做"
+ try await eventHandler(.willBegin(
+ step: 1,
+ total: 1,
+ detail: "正在\(verb)“\(record.summary)”"
+ ))
+ let updated = try fileOperationEngine.transition(
+ record,
+ to: targetState,
+ conflictChoice: conflictChoice
+ )
+ return updated
+ }
+
+ private func rollback(
+ actions: [ReversibleFileAction]
+ ) -> (actions: [ReversibleFileAction], displacements: [ConflictDisplacement], succeeded: Bool) {
+ guard !actions.isEmpty else { return ([], [], true) }
+ let temporaryRecord = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "回滚未完成操作",
+ state: .applied,
+ actions: actions
+ )
+ do {
+ let rolledBack = try fileOperationEngine.transition(
+ temporaryRecord,
+ to: .undone,
+ conflictChoice: .cancel
+ )
+ return (rolledBack.actions, rolledBack.displacements, true)
+ } catch {
+ return (actions, [], false)
+ }
+ }
+}
+
+/// 主界面展示的进度状态,不包含用户文件的完整路径。
+struct FileOperationProgressState: Equatable, Identifiable, Sendable {
+ let id: UUID
+ let title: String
+ var detail: String
+ var completedUnitCount: Int
+ var totalUnitCount: Int
+ var isCancelling: Bool
+ let allowsCancellation: Bool
+
+ var fractionCompleted: Double? {
+ guard totalUnitCount > 0 else { return nil }
+ return Double(completedUnitCount) / Double(totalUnitCount)
+ }
+}
diff --git a/Sources/SpatialFolder/FolderAccessRepository.swift b/Sources/SpatialFolder/FolderAccessRepository.swift
new file mode 100644
index 0000000..6d9f21a
--- /dev/null
+++ b/Sources/SpatialFolder/FolderAccessRepository.swift
@@ -0,0 +1,127 @@
+import Foundation
+
+struct FileInfoSnapshot: Equatable, Sendable {
+ var size: Int64
+ var modificationDate: Date?
+}
+
+/// 短时文件系统读取与目标规划仓库。它与主模型隔离,避免 `fileExists`、属性读取和批量
+/// 目标命名随着目录规模或网络磁盘延迟而卡住 UI。
+actor FolderAccessRepository {
+ private let fileManager: FileManager
+
+ init(fileManager: FileManager = .default) {
+ self.fileManager = fileManager
+ }
+
+ func containsNameConflict(sources: [URL], destinationFolder: URL) -> Bool {
+ sources.contains {
+ fileManager.fileExists(atPath: destinationFolder.appendingPathComponent($0.lastPathComponent).path)
+ }
+ }
+
+ func itemExists(at url: URL) -> Bool {
+ fileManager.fileExists(atPath: url.path)
+ }
+
+ func uniqueDestination(
+ for source: URL,
+ in folder: URL,
+ excluding reservedPaths: Set = []
+ ) -> URL {
+ let extensionName = source.pathExtension
+ let baseName = source.deletingPathExtension().lastPathComponent
+ var index = 1
+ var target = folder.appendingPathComponent(source.lastPathComponent)
+ while fileManager.fileExists(atPath: target.path) || reservedPaths.contains(target.path) {
+ index += 1
+ target = folder.appendingPathComponent("\(baseName) \(index)").appendingPathExtension(extensionName)
+ }
+ return target
+ }
+
+ func uniqueNamedItem(in folder: URL, baseName: String, fileExtension: String? = nil) -> URL {
+ var index = 1
+ func candidate(_ index: Int) -> URL {
+ let suffix = index == 1 ? "" : " \(index)"
+ let base = folder.appendingPathComponent("\(baseName)\(suffix)")
+ guard let fileExtension else { return base }
+ return base.appendingPathExtension(fileExtension)
+ }
+ var result = candidate(index)
+ while fileManager.fileExists(atPath: result.path) {
+ index += 1
+ result = candidate(index)
+ }
+ return result
+ }
+
+ func transferPlans(
+ sources: [URL],
+ destinationFolder: URL,
+ move: Bool,
+ policy: ConflictChoice
+ ) -> [FileTransferPlan] {
+ var reservedPaths = Set()
+ return sources.map { source in
+ var destination = destinationFolder.appendingPathComponent(source.lastPathComponent)
+ let collidesWithBatch = reservedPaths.contains(destination.path)
+ let existsBeforeBatch = fileManager.fileExists(atPath: destination.path)
+ var replacesExisting = false
+ if collidesWithBatch || (existsBeforeBatch && policy == .keepBoth) {
+ destination = uniqueDestination(for: source, in: destinationFolder, excluding: reservedPaths)
+ } else if existsBeforeBatch && policy == .replace {
+ replacesExisting = true
+ }
+ reservedPaths.insert(destination.path)
+ return FileTransferPlan(
+ source: source,
+ destination: destination,
+ move: move,
+ replacesExistingDestination: replacesExisting
+ )
+ }
+ }
+
+ func duplicatePlans(for sources: [URL]) -> [FileTransferPlan] {
+ var reservedPaths = Set()
+ return sources.map { source in
+ let destination = uniqueDestination(
+ for: source,
+ in: source.deletingLastPathComponent(),
+ excluding: reservedPaths
+ )
+ reservedPaths.insert(destination.path)
+ return FileTransferPlan(
+ source: source,
+ destination: destination,
+ move: false,
+ replacesExistingDestination: false
+ )
+ }
+ }
+
+ func compressionPlans(for sources: [URL], destinationFolder: URL) -> [FileCompressionPlan] {
+ var reservedPaths = Set()
+ return sources.map { source in
+ let requested = destinationFolder
+ .appendingPathComponent(source.deletingPathExtension().lastPathComponent)
+ .appendingPathExtension("zip")
+ let destination = uniqueDestination(
+ for: requested,
+ in: destinationFolder,
+ excluding: reservedPaths
+ )
+ reservedPaths.insert(destination.path)
+ return FileCompressionPlan(source: source, destination: destination)
+ }
+ }
+
+ func attributes(of url: URL) throws -> FileInfoSnapshot {
+ let attributes = try fileManager.attributesOfItem(atPath: url.path)
+ return FileInfoSnapshot(
+ size: (attributes[.size] as? NSNumber)?.int64Value ?? 0,
+ modificationDate: attributes[.modificationDate] as? Date
+ )
+ }
+}
diff --git a/Sources/SpatialFolder/FolderCanvasModel.swift b/Sources/SpatialFolder/FolderCanvasModel.swift
index b4bc032..a345edc 100644
--- a/Sources/SpatialFolder/FolderCanvasModel.swift
+++ b/Sources/SpatialFolder/FolderCanvasModel.swift
@@ -5,7 +5,6 @@ import SwiftUI
struct FolderItem: Identifiable, Hashable {
let url: URL
- let icon: NSImage
let tags: [String]
let resourceID: String?
@@ -21,12 +20,38 @@ private struct RecentFolderBookmark: Codable {
var fallbackPath: String
}
+private struct LayoutHistoryEntry {
+ var canvas: SavedCanvas
+ var operationID: UUID
+ var transitionDate: Date
+}
+
+struct PendingFileConflict: Identifiable, Equatable {
+ let id = UUID()
+ let title: String
+ let message: String
+}
+
+private struct PreparedImport: Sendable {
+ var hasConflict: Bool
+ var plans: [FileTransferPlan]
+}
+
+private struct PreparedRename: Sendable {
+ var destination: URL
+ var replacesExisting: Bool
+ var hasConflict: Bool
+}
+
+/// 画布的主线程编排模型。
+///
+/// 它保存界面需要观察的状态,并把布局仓库、后台扫描、文件操作协调器和会话锁连接起来。
+/// 耗时 I/O 必须交给独立 service/actor,不能直接塞回这个类型的主线程方法。
@MainActor
final class FolderCanvasModel: ObservableObject {
let mainCanvasCapacity = 64
private let defaultIconScale: CGFloat = 1.25
- private let initialColumns = 8
private let recentFoldersKey = "recentFolderPaths"
private let recentFolderBookmarksKey = "recentFolderBookmarksV2"
private let lastFolderBookmarkKey = "lastOpenedFolderBookmarkV2"
@@ -34,8 +59,23 @@ final class FolderCanvasModel: ObservableObject {
private let maximumUndoDepth = 50
private let grid: CGFloat = 24
private let layoutStore: CanvasLayoutStore
+ private let operationStore: OperationHistoryStore
+ private let operationJournal: OperationJournalStore
+ private let operationJournalDiskStore: OperationJournalDiskStore
+ private let fileOperationEngine: FileOperationEngine
private let defaults: UserDefaults
private let monitorFolders: Bool
+ private let sessionLockDirectory: URL
+ private let sessionLockingEnabled: Bool
+ private let directoryScanner: FolderDirectoryScanner
+ private let scanService: FolderScanService
+ private let scansAsynchronously: Bool
+ private let fileOperationCoordinator: FileOperationCoordinator
+ private let folderAccessRepository: FolderAccessRepository
+ private let recoveryAnalyzer: RecoveryAnalyzer
+ private let fileOperationsAsynchronously: Bool
+ private let layoutEngine = CanvasLayoutEngine()
+ private let iconCache = FileIconCache()
@Published private(set) var folderURL: URL?
@Published private(set) var items: [FolderItem] = []
@@ -52,6 +92,13 @@ final class FolderCanvasModel: ObservableObject {
@Published private(set) var folderUnavailable = false
@Published private(set) var canUndo = false
@Published private(set) var canRedo = false
+ @Published private(set) var operationRecords: [OperationRecord] = []
+ @Published private(set) var operationHistoryIsBlocked = false
+ @Published private(set) var sessionIsReadOnly = false
+ @Published private(set) var sessionLockOwner: CanvasSessionOwner?
+ @Published private(set) var isRefreshing = false
+ @Published private(set) var fileOperationProgress: FileOperationProgressState?
+ @Published var pendingConflict: PendingFileConflict?
@Published private(set) var backupCount = 0
@Published private(set) var canvasSize: CGSize
@Published private(set) var currentDisplaySize: CGSize
@@ -60,10 +107,18 @@ final class FolderCanvasModel: ObservableObject {
@Published private(set) var recentFolders: [URL] = []
@Published var searchText = ""
@Published var infoItem: FolderItem?
+ @Published private(set) var infoSnapshot: FileInfoSnapshot?
+ @Published private(set) var recoveryCases: [RecoveryCase] = []
+ @Published private(set) var isLoadingOperationHistory = false
+ @Published var isRecoveryWizardPresented = false
private var folderMonitor: DispatchSourceFileSystemObject?
private var refreshWorkItem: DispatchWorkItem?
- private var iconCache: [String: NSImage] = [:]
+ private var scanTask: Task?
+ private var fileOperationTask: Task?
+ private var journalWriteTask: Task?
+ private var operationHistoryLoadTask: Task?
+ private var scanGeneration = UUID()
private var savedCanvas = SavedCanvas()
private var needsInitialArrangement = false
private var needsGridMigration = false
@@ -72,19 +127,43 @@ final class FolderCanvasModel: ObservableObject {
private var canvasKey: String?
private var rootResourceID: String?
private var lastFolderBookmark: Data?
- private var undoStack: [SavedCanvas] = []
- private var redoStack: [SavedCanvas] = []
+ private var undoStack: [LayoutHistoryEntry] = []
+ private var redoStack: [LayoutHistoryEntry] = []
+ private var conflictResolution: ((ConflictChoice) -> Void)?
+ private var pendingDropPoints: [UUID: CGPoint] = [:]
+ /// 持有对象即代表当前进程拥有这张画布的唯一写入权。
+ private var sessionLock: CanvasSessionLock?
init(
layoutStore: CanvasLayoutStore = CanvasLayoutStore(),
+ operationStore: OperationHistoryStore = OperationHistoryStore(),
+ fileOperationEngine: FileOperationEngine = FileOperationEngine(),
userDefaults: UserDefaults = .standard,
autoOpenLastFolder: Bool = true,
initialCanvasSize: CGSize? = nil,
- monitorFolders: Bool = true
+ monitorFolders: Bool = true,
+ sessionLockDirectory: URL = CanvasSessionLock.defaultLockDirectory(),
+ sessionLockingEnabled: Bool = true,
+ directoryScanner: FolderDirectoryScanner = FolderDirectoryScanner(),
+ scansAsynchronously: Bool = true,
+ fileOperationsAsynchronously: Bool = true
) {
self.layoutStore = layoutStore
+ self.operationStore = operationStore
+ operationJournal = OperationJournalStore(legacyStore: operationStore)
+ operationJournalDiskStore = OperationJournalDiskStore(legacyStore: operationStore)
+ self.fileOperationEngine = fileOperationEngine
defaults = userDefaults
self.monitorFolders = monitorFolders
+ self.sessionLockDirectory = sessionLockDirectory
+ self.sessionLockingEnabled = sessionLockingEnabled
+ self.directoryScanner = directoryScanner
+ scanService = FolderScanService(scanner: directoryScanner)
+ self.scansAsynchronously = scansAsynchronously
+ fileOperationCoordinator = FileOperationCoordinator(fileOperationEngine: fileOperationEngine)
+ folderAccessRepository = FolderAccessRepository(fileManager: fileOperationEngine.fileManager)
+ recoveryAnalyzer = RecoveryAnalyzer(fileManager: fileOperationEngine.fileManager)
+ self.fileOperationsAsynchronously = fileOperationsAsynchronously
let startingSize = initialCanvasSize ?? NSScreen.main?.frame.size ?? CGSize(width: 1440, height: 900)
canvasSize = startingSize
currentDisplaySize = startingSize
@@ -120,6 +199,18 @@ final class FolderCanvasModel: ObservableObject {
var desktopCanvasSize: CGSize { canvasSize }
+ /// 统一供界面和命令判断,避免只在按钮层禁用而底层仍然修改布局。
+ var canEditLayout: Bool {
+ folderURL != nil && !layoutIsBlocked && !folderUnavailable && !sessionIsReadOnly &&
+ !isLoadingOperationHistory && !isLocked
+ }
+
+ var isFileOperationInProgress: Bool { fileOperationProgress != nil }
+
+ func icon(for item: FolderItem) -> NSImage {
+ iconCache.icon(for: item.url)
+ }
+
var defaultDesktopWallpaperURL: URL? {
guard let screen = NSApp.keyWindow?.screen ?? NSScreen.main else { return nil }
return NSWorkspace.shared.desktopImageURL(for: screen)
@@ -134,6 +225,8 @@ final class FolderCanvasModel: ObservableObject {
}
}
+ // MARK: - 外观、空间打开与目录扫描
+
func setAppearanceMode(_ mode: String) {
appearanceMode = mode
defaults.set(mode, forKey: "appearanceMode")
@@ -150,6 +243,10 @@ final class FolderCanvasModel: ObservableObject {
}
func open(folder: URL) {
+ guard !isFileOperationInProgress else {
+ errorMessage = "请等待当前文件操作完成,或先取消操作,再切换空间。"
+ return
+ }
let standardized = folder.standardizedFileURL
guard Self.directoryExists(at: standardized) else {
errorMessage = "无法打开文件夹:文件夹不存在或暂时不可用。"
@@ -157,19 +254,31 @@ final class FolderCanvasModel: ObservableObject {
}
folderMonitor?.cancel()
refreshWorkItem?.cancel()
+ scanTask?.cancel()
+ operationHistoryLoadTask?.cancel()
+ scanGeneration = UUID()
+ iconCache.removeAll()
+ sessionLock?.release()
+ sessionLock = nil
+ sessionIsReadOnly = false
+ sessionLockOwner = nil
folderURL = standardized
rootResourceID = persistentResourceIdentifier(for: standardized) ?? "path:\(standardized.path)"
canvasKey = rootResourceID.map(layoutStore.canvasKey(for:))
+ acquireSessionLock()
lastFolderBookmark = makeBookmark(for: standardized)
saveLastOpenedFolder(standardized)
remember(folder: standardized)
selectedIDs = []
undoStack = []
redoStack = []
- updateUndoAvailability()
+ pendingConflict = nil
+ conflictResolution = nil
folderUnavailable = false
loadSavedCanvas()
+ loadOperationHistory()
refreshItems()
+ updateUndoAvailability()
if monitorFolders { watchFolder() }
}
@@ -180,47 +289,90 @@ final class FolderCanvasModel: ObservableObject {
return
}
folderUnavailable = false
- do {
- let keys: Set = [
- .isHiddenKey, .tagNamesKey, .fileResourceIdentifierKey, .volumeIdentifierKey
- ]
- let urls = try FileManager.default.contentsOfDirectory(
- at: folderURL,
- includingPropertiesForKeys: Array(keys),
- options: [.skipsHiddenFiles]
- )
- let freshItems = urls.compactMap { url -> FolderItem? in
- let values = try? url.resourceValues(forKeys: keys)
- guard values?.isHidden != true else { return nil }
- return FolderItem(
- url: url,
- icon: cachedIcon(for: url),
- tags: values?.tagNames ?? [],
- resourceID: persistentResourceIdentifier(values: values)
- )
- }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
- let currentPaths = Set(freshItems.map(\.id))
- iconCache = iconCache.filter { currentPaths.contains($0.key) }
- items = freshItems
- selectedIDs.formIntersection(currentPaths)
-
- var changed = reconcileResourcePaths(freshItems)
- guard !layoutIsBlocked else { return }
- if needsInitialArrangement {
- arrangeInitialItems(freshItems)
- needsInitialArrangement = false
- changed = true
- } else if needsGridMigration {
- arrangeInitialItems(freshItems)
- needsGridMigration = false
+ if scansAsynchronously {
+ refreshItemsInBackground(folder: folderURL)
+ } else {
+ do {
+ applyScan(try directoryScanner.scan(folder: folderURL), for: folderURL)
+ } catch {
+ errorMessage = "无法读取文件夹:\(error.localizedDescription)"
+ }
+ }
+ }
+
+ /// 生产环境使用后台扫描;generation 防止切换空间后旧结果覆盖新画布。
+ private func refreshItemsInBackground(folder: URL) {
+ scanTask?.cancel()
+ let generation = UUID()
+ scanGeneration = generation
+ isRefreshing = true
+ scanTask = Task { [weak self, scanService] in
+ do {
+ let entries = try await scanService.scan(folder: folder)
+ guard !Task.isCancelled,
+ let self,
+ self.scanGeneration == generation,
+ self.folderURL?.standardizedFileURL == folder.standardizedFileURL else { return }
+ self.applyScan(entries, for: folder)
+ self.isRefreshing = false
+ } catch is CancellationError {
+ if self?.scanGeneration == generation { self?.isRefreshing = false }
+ } catch {
+ guard let self, self.scanGeneration == generation else { return }
+ self.isRefreshing = false
+ self.errorMessage = "无法读取文件夹:\(error.localizedDescription)"
+ }
+ }
+ }
+
+ /// 把扫描快照一次性应用到主线程状态,避免边扫描边发布造成界面反复重排。
+ private func applyScan(_ entries: [ScannedFolderEntry], for folder: URL) {
+ guard folderURL?.standardizedFileURL == folder.standardizedFileURL else { return }
+ let freshItems = entries.map {
+ FolderItem(url: $0.url, tags: $0.tags, resourceID: $0.resourceID)
+ }
+ items = freshItems
+ let currentPaths = Set(freshItems.map(\.id))
+ selectedIDs.formIntersection(currentPaths)
+
+ var changed = reconcileResourcePaths(freshItems)
+ guard !layoutIsBlocked else { return }
+ if needsInitialArrangement {
+ arrangeInitialItems(freshItems)
+ needsInitialArrangement = false
+ changed = true
+ } else if needsGridMigration {
+ arrangeInitialItems(freshItems)
+ needsGridMigration = false
+ changed = true
+ } else {
+ changed = assignPositionsToNewItems(freshItems) || changed
+ }
+ changed = enforceCapacityAndBounds(freshItems) || changed
+ if changed { persist(makeBackup: false) }
+ capturePendingCanvasMetadataAfterRefresh()
+ }
+
+ /// 文件操作先落盘、扫描稍后完成时,在这里补记新项目的画布位置。
+ private func capturePendingCanvasMetadataAfterRefresh() {
+ let kinds: Set = [.createFolder, .createDocument, .duplicate, .copyItems, .moveItems, .compress]
+ var changed = false
+ for index in operationRecords.indices where
+ operationRecords[index].state == .applied &&
+ operationRecords[index].canvasItems.isEmpty &&
+ kinds.contains(operationRecords[index].kind) {
+ var record = operationRecords[index]
+ captureCanvasMetadata(in: &record)
+ if !record.canvasItems.isEmpty {
+ operationRecords[index] = record
changed = true
- } else {
- changed = assignPositionsToNewItems(freshItems) || changed
}
- changed = enforceCapacityAndBounds(freshItems) || changed
- if changed { persist(makeBackup: false) }
- } catch {
- errorMessage = "无法读取文件夹:\(error.localizedDescription)"
+ }
+ if changed {
+ let changedRecords = operationRecords.filter { record in
+ kinds.contains(record.kind) && !record.canvasItems.isEmpty
+ }
+ enqueueOperationJournalUpsert(changedRecords)
}
}
@@ -229,8 +381,8 @@ final class FolderCanvasModel: ObservableObject {
}
func move(_ item: FolderItem, to point: CGPoint) {
- guard !isLocked, !inboxIDs.contains(item.id) else { return }
- captureUndoSnapshot()
+ guard canEditLayout, !inboxIDs.contains(item.id) else { return }
+ captureUndoSnapshot(summary: "移动图标")
positions[item.id] = snapped(point, scale: scale(for: item))
persist(makeBackup: true)
}
@@ -240,16 +392,26 @@ final class FolderCanvasModel: ObservableObject {
}
func setScale(_ scale: CGFloat, for item: FolderItem) {
- guard !isLocked else { return }
- captureUndoSnapshot()
+ setScale(scale, for: [item])
+ }
+
+ func setScale(_ scale: CGFloat, for targetItems: [FolderItem]) {
+ guard canEditLayout else { return }
+ let targets = targetItems.filter { !inboxIDs.contains($0.id) }
+ guard !targets.isEmpty else { return }
+ captureUndoSnapshot(summary: "调整图标和字体大小")
let adjustedScale = min(max(scale, 0.7), 1.8)
- scales[item.id] = adjustedScale
- if let position = positions[item.id] {
- positions[item.id] = snapped(CGPoint(x: position.x, y: position.y), scale: adjustedScale)
+ for item in targets {
+ scales[item.id] = adjustedScale
+ if let position = positions[item.id] {
+ positions[item.id] = snapped(CGPoint(x: position.x, y: position.y), scale: adjustedScale)
+ }
}
persist(makeBackup: true)
}
+ // MARK: - 选择、框选和整体拖动
+
func select(_ item: FolderItem, extendingSelection: Bool) {
guard !inboxIDs.contains(item.id) else { return }
if extendingSelection {
@@ -297,30 +459,29 @@ final class FolderCanvasModel: ObservableObject {
}
func beginDragging(_ item: FolderItem) {
- guard !isLocked, !inboxIDs.contains(item.id) else { return }
+ guard canEditLayout, !inboxIDs.contains(item.id) else { return }
if !selectedIDs.contains(item.id) { selectedIDs = [item.id] }
draggingIDs = selectedIDs
}
func updateDrag(translation: CGSize) {
guard !draggingIDs.isEmpty else { return }
- dragTranslation = translation
+ dragTranslation = constrainedGroupTranslation(translation)
}
func finishDrag() {
- guard !isLocked, !draggingIDs.isEmpty else {
+ guard canEditLayout, !draggingIDs.isEmpty else {
dragTranslation = .zero
draggingIDs = []
return
}
let moved = abs(dragTranslation.width) > 0.5 || abs(dragTranslation.height) > 0.5
- if moved { captureUndoSnapshot() }
+ if moved { captureUndoSnapshot(summary: "移动所选图标") }
for id in draggingIDs {
guard let position = positions[id] else { continue }
- let itemScale = scales[id] ?? defaultIconScale
- positions[id] = snapped(
- CGPoint(x: position.x + dragTranslation.width, y: position.y + dragTranslation.height),
- scale: itemScale
+ positions[id] = CanvasPoint(
+ x: position.x + dragTranslation.width,
+ y: position.y + dragTranslation.height
)
}
dragTranslation = .zero
@@ -328,9 +489,38 @@ final class FolderCanvasModel: ObservableObject {
if moved { persist(makeBackup: true) }
}
+ /// 先把位移吸附到网格,再按整个选择集合的外接边界统一限位。
+ /// 这样任意一个图标碰到边缘时,整组一起停止,不会破坏组内相对位置。
+ private func constrainedGroupTranslation(_ proposed: CGSize) -> CGSize {
+ let activeItems = items.filter { draggingIDs.contains($0.id) && positions[$0.id] != nil }
+ guard !activeItems.isEmpty else { return .zero }
+ var minimumDX = -CGFloat.greatestFiniteMagnitude
+ var maximumDX = CGFloat.greatestFiniteMagnitude
+ var minimumDY = -CGFloat.greatestFiniteMagnitude
+ var maximumDY = CGFloat.greatestFiniteMagnitude
+ for item in activeItems {
+ guard let position = positions[item.id] else { continue }
+ let rect = iconRect(at: position, scale: scale(for: item))
+ minimumDX = max(minimumDX, -rect.minX)
+ maximumDX = min(maximumDX, canvasSize.width - rect.maxX)
+ minimumDY = max(minimumDY, -rect.minY)
+ maximumDY = min(maximumDY, canvasSize.height - rect.maxY)
+ }
+ guard minimumDX <= maximumDX, minimumDY <= maximumDY else { return .zero }
+ let snappedDX = (proposed.width / grid).rounded() * grid
+ let snappedDY = (proposed.height / grid).rounded() * grid
+ return CGSize(
+ width: min(maximumDX, max(minimumDX, snappedDX)),
+ height: min(maximumDY, max(minimumDY, snappedDY))
+ )
+ }
+
+ // MARK: - 画布布局命令
+
func setLocked(_ locked: Bool) {
+ guard !sessionIsReadOnly, !isLoadingOperationHistory, !layoutIsBlocked, folderURL != nil else { return }
guard isLocked != locked else { return }
- captureUndoSnapshot()
+ captureUndoSnapshot(summary: locked ? "锁定画布" : "解锁画布")
isLocked = locked
persist(makeBackup: true)
}
@@ -338,38 +528,85 @@ final class FolderCanvasModel: ObservableObject {
func toggleLocked() { setLocked(!isLocked) }
func moveToInbox(_ item: FolderItem) {
- guard !isLocked, !inboxIDs.contains(item.id) else { return }
- captureUndoSnapshot()
- inboxIDs.insert(item.id)
- positions.removeValue(forKey: item.id)
- selectedIDs.remove(item.id)
+ moveToInbox([item])
+ }
+
+ func moveToInbox(_ targetItems: [FolderItem]) {
+ guard canEditLayout else { return }
+ let targets = targetItems.filter { !inboxIDs.contains($0.id) }
+ guard !targets.isEmpty else { return }
+ captureUndoSnapshot(summary: "将 \(targets.count) 个项目移到待放置区")
+ for item in targets {
+ inboxIDs.insert(item.id)
+ positions.removeValue(forKey: item.id)
+ selectedIDs.remove(item.id)
+ }
persist(makeBackup: true)
}
func placeFromInbox(_ item: FolderItem) {
- guard !isLocked, inboxIDs.contains(item.id) else { return }
- let activeItems = items.filter { !inboxIDs.contains($0.id) && positions[$0.id] != nil }
- guard activeItems.count < mainCanvasCapacity,
- let point = nextAvailableGridPoint(for: item, among: activeItems) else {
+ placeFromInbox([item])
+ }
+
+ func placeFromInbox(_ targetItems: [FolderItem]) {
+ guard canEditLayout else { return }
+ let targets = targetItems.filter { inboxIDs.contains($0.id) }
+ guard !targets.isEmpty else { return }
+ var activeItems = items.filter { !inboxIDs.contains($0.id) && positions[$0.id] != nil }
+ var occupiedRects = activeItems.compactMap { active -> CGRect? in
+ guard let position = positions[active.id] else { return nil }
+ return iconRect(at: position, scale: scale(for: active))
+ }
+ var placements: [(FolderItem, CanvasPoint)] = []
+ for item in targets where activeItems.count < mainCanvasCapacity {
+ let point = (0.. [FolderItem] {
+ selectedIDs.contains(item.id) ? selectedItems : [item]
}
func recoverOutOfBoundsItems() {
- guard !isLocked else { return }
+ guard canEditLayout else { return }
let before = positions
- captureUndoSnapshot()
+ let operationID = captureUndoSnapshot(summary: "找回越界项目")
for item in items where !inboxIDs.contains(item.id) {
guard let point = positions[item.id] else { continue }
positions[item.id] = snapped(CGPoint(x: point.x, y: point.y), scale: scale(for: item))
}
if before == positions {
undoStack.removeLast()
+ operationRecords.removeAll { $0.id == operationID }
+ persistOperationHistory()
updateUndoAvailability()
errorMessage = "当前没有越界项目。"
} else {
@@ -384,40 +621,86 @@ final class FolderCanvasModel: ObservableObject {
}
func setCurrentDisplayAsReferenceCanvas() {
- guard folderURL != nil, !layoutIsBlocked, !isLocked else { return }
+ guard canEditLayout else { return }
guard abs(canvasSize.width - currentDisplaySize.width) > 1 ||
abs(canvasSize.height - currentDisplaySize.height) > 1 else {
errorMessage = "当前显示器已经是基准画布。"
return
}
- captureUndoSnapshot()
+ captureUndoSnapshot(summary: "重设基准画布")
resizeLogicalCanvas(to: currentDisplaySize)
_ = enforceCapacityAndBounds(items)
persist(makeBackup: true)
}
func undoLayoutChange() {
- guard let previous = undoStack.popLast() else { return }
+ guard !sessionIsReadOnly, !isLoadingOperationHistory, let previous = undoStack.popLast() else { return }
syncSavedCanvas()
- redoStack.append(savedCanvas)
- applySavedCanvas(previous)
+ let now = Date()
+ redoStack.append(LayoutHistoryEntry(
+ canvas: savedCanvas,
+ operationID: previous.operationID,
+ transitionDate: now
+ ))
+ applySavedCanvas(previous.canvas)
reconcileAfterLayoutRestore()
persist(makeBackup: true)
+ setOperationState(id: previous.operationID, state: .undone, transitionDate: now)
updateUndoAvailability()
}
func redoLayoutChange() {
- guard let next = redoStack.popLast() else { return }
+ guard !sessionIsReadOnly, !isLoadingOperationHistory, let next = redoStack.popLast() else { return }
syncSavedCanvas()
- undoStack.append(savedCanvas)
- applySavedCanvas(next)
+ let now = Date()
+ undoStack.append(LayoutHistoryEntry(
+ canvas: savedCanvas,
+ operationID: next.operationID,
+ transitionDate: now
+ ))
+ applySavedCanvas(next.canvas)
reconcileAfterLayoutRestore()
persist(makeBackup: true)
+ setOperationState(id: next.operationID, state: .applied, transitionDate: now)
updateUndoAvailability()
}
+ // MARK: - 统一撤销、重做和布局交换
+
+ func undoLastAction() {
+ let layout = undoStack.last
+ let file = operationRecords
+ .filter { $0.isFileReversible && $0.state == .applied }
+ .max { $0.transitionDate < $1.transitionDate }
+ if let layout, file == nil || layout.transitionDate >= file!.transitionDate {
+ undoLayoutChange()
+ } else if let file {
+ transitionFileOperation(id: file.id, to: .undone, conflictChoice: .cancel)
+ }
+ }
+
+ func redoLastAction() {
+ let layout = redoStack.last
+ let file = operationRecords
+ .filter { $0.isFileReversible && $0.state == .undone }
+ .max { $0.transitionDate < $1.transitionDate }
+ if let layout, file == nil || layout.transitionDate >= file!.transitionDate {
+ redoLayoutChange()
+ } else if let file {
+ transitionFileOperation(id: file.id, to: .applied, conflictChoice: .cancel)
+ }
+ }
+
+ func resolvePendingConflict(_ choice: ConflictChoice) {
+ let resolution = conflictResolution
+ conflictResolution = nil
+ pendingConflict = nil
+ resolution?(choice)
+ }
+
func resetLayout() {
- if !layoutIsBlocked { captureUndoSnapshot() }
+ guard !sessionIsReadOnly, !isLoadingOperationHistory else { return }
+ if !layoutIsBlocked { captureUndoSnapshot(summary: "重置当前布局") }
layoutIsBlocked = false
positions = [:]
scales = [:]
@@ -427,9 +710,9 @@ final class FolderCanvasModel: ObservableObject {
}
func restoreLatestBackup() {
- guard let canvasKey else { return }
+ guard !sessionIsReadOnly, !isLoadingOperationHistory, let canvasKey else { return }
do {
- if !layoutIsBlocked { captureUndoSnapshot() }
+ if !layoutIsBlocked { captureUndoSnapshot(summary: "恢复布局备份") }
let restored = try layoutStore.restoreLatestBackup(canvasKey: canvasKey)
layoutIsBlocked = false
applySavedCanvas(restored)
@@ -472,6 +755,82 @@ final class FolderCanvasModel: ObservableObject {
try layoutStore.export(savedCanvas, to: url)
}
+ func diagnosticsData() throws -> Data {
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development"
+ let report = PrivacySafeDiagnostics(
+ generatedAt: Date(),
+ appVersion: version,
+ macOSVersion: ProcessInfo.processInfo.operatingSystemVersionString,
+ logicalCanvasSize: CanvasDimensions(canvasSize),
+ currentDisplaySize: CanvasDimensions(currentDisplaySize),
+ visibleItemCount: displayedItems.count,
+ inboxItemCount: inboxItems.count,
+ operationCount: operationRecords.count,
+ recentOperations: operationRecords.suffix(100).map { record in
+ DiagnosticOperation(
+ category: record.category,
+ kind: record.kind,
+ state: record.state,
+ occurredAt: record.transitionDate,
+ actionCount: record.actions.count,
+ errorType: record.detail == nil ? nil : "operation_error"
+ )
+ }
+ )
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ return try encoder.encode(report)
+ }
+
+ func exportDiagnostics() {
+ let panel = NSSavePanel()
+ panel.title = "导出隐私安全的诊断信息"
+ panel.nameFieldStringValue = "空间文件夹-诊断.json"
+ panel.allowedContentTypes = [.json]
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ do {
+ try diagnosticsData().write(to: url, options: .atomic)
+ errorMessage = "诊断信息已导出。内容不包含文件名或完整路径。"
+ } catch {
+ errorMessage = "无法导出诊断信息:\(error.localizedDescription)"
+ }
+ }
+
+ func revealOperationHistory() {
+ do {
+ try FileManager.default.createDirectory(at: operationStore.directory, withIntermediateDirectories: true)
+ NSWorkspace.shared.open(operationStore.directory)
+ } catch {
+ errorMessage = "无法打开操作记录位置:\(error.localizedDescription)"
+ }
+ }
+
+ func archiveDamagedOperationHistoryAndContinue() {
+ guard operationHistoryIsBlocked, let canvasKey else { return }
+ let alert = NSAlert()
+ alert.messageText = "存档损坏记录并重新开始?"
+ alert.informativeText = "原操作记录会移入 Corrupt 存档目录,不会删除。重新开始后,旧操作不能在 App 内撤销,但真实文件不会被改动。"
+ alert.addButton(withTitle: "存档并继续")
+ alert.addButton(withTitle: "取消")
+ guard alert.runModal() == .alertFirstButtonReturn else { return }
+ let journal = operationJournal
+ journalWriteTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let archive = try await journal.archiveCorruptHistoryAndReset(canvasKey: canvasKey)
+ guard self.canvasKey == canvasKey else { return }
+ self.operationRecords = []
+ self.operationHistoryIsBlocked = false
+ self.updateUndoAvailability()
+ self.errorMessage = "损坏记录已存档为“\(archive.lastPathComponent)”,可以继续使用。"
+ } catch {
+ guard self.canvasKey == canvasKey else { return }
+ self.errorMessage = "无法存档损坏操作记录:\(error.localizedDescription)"
+ }
+ }
+ }
+
func importLayout() {
let panel = NSOpenPanel()
panel.title = "导入空间布局"
@@ -489,8 +848,9 @@ final class FolderCanvasModel: ObservableObject {
}
func importLayout(from url: URL) throws {
+ guard !sessionIsReadOnly, !isLoadingOperationHistory else { return }
let imported = try layoutStore.importedCanvas(from: url, expectedRootResourceID: rootResourceID)
- if !layoutIsBlocked { captureUndoSnapshot() }
+ if !layoutIsBlocked { captureUndoSnapshot(summary: "导入空间布局") }
layoutIsBlocked = false
applySavedCanvas(imported)
reconcileAfterLayoutRestore()
@@ -516,7 +876,7 @@ final class FolderCanvasModel: ObservableObject {
guard Self.directoryExists(at: replacement) else { return }
let previous = savedCanvas
open(folder: replacement)
- guard preservingCurrentLayout else { return }
+ guard preservingCurrentLayout, !sessionIsReadOnly else { return }
var transferred = remappedCanvas(previous, to: replacement.standardizedFileURL, matching: items)
transferred.rootResourceID = rootResourceID
transferred.resourcePaths = [:]
@@ -549,45 +909,133 @@ final class FolderCanvasModel: ObservableObject {
return remapped
}
- func open(_ item: FolderItem) { NSWorkspace.shared.open(item.url) }
+ // MARK: - Finder 风格命令与真实文件事务
+
+ func open(_ item: FolderItem) { open([item]) }
+
+ func open(_ targetItems: [FolderItem]) {
+ for item in targetItems { NSWorkspace.shared.open(item.url) }
+ }
func reveal(_ item: FolderItem) {
- NSWorkspace.shared.activateFileViewerSelecting([item.url])
+ reveal([item])
}
- func showInfo(_ item: FolderItem) { infoItem = item }
+ func reveal(_ targetItems: [FolderItem]) {
+ NSWorkspace.shared.activateFileViewerSelecting(targetItems.map(\.url))
+ }
- func share(_ item: FolderItem) {
+ func showInfo(_ item: FolderItem) {
+ infoItem = item
+ infoSnapshot = nil
+ let repository = folderAccessRepository
+ Task { [weak self] in
+ do {
+ let snapshot = try await repository.attributes(of: item.url)
+ guard self?.infoItem?.id == item.id else { return }
+ self?.infoSnapshot = snapshot
+ } catch {
+ guard self?.infoItem?.id == item.id else { return }
+ self?.errorMessage = "无法读取文件信息:\(error.localizedDescription)"
+ }
+ }
+ }
+
+ func share(_ item: FolderItem) { share([item]) }
+
+ func share(_ targetItems: [FolderItem]) {
+ guard !targetItems.isEmpty else { return }
guard let view = NSApp.keyWindow?.contentView else {
errorMessage = "当前没有可用于分享的窗口。"
return
}
- let picker = NSSharingServicePicker(items: [item.url])
+ let picker = NSSharingServicePicker(items: targetItems.map(\.url))
let anchor = NSRect(x: view.bounds.midX, y: view.bounds.midY, width: 1, height: 1)
picker.show(relativeTo: anchor, of: view, preferredEdge: .minY)
}
func toggleTag(_ tag: String, for item: FolderItem) {
- var tags = item.tags
+ toggleTag(tag, for: [item])
+ }
+
+ func toggleTag(_ tag: String, for targetItems: [FolderItem]) {
+ guard realFileMutationsAllowed(), !targetItems.isEmpty else { return }
let normalized = normalizedTagName(tag)
- if let index = tags.firstIndex(where: { normalizedTagName($0) == normalized }) {
- tags.remove(at: index)
- } else {
- tags.append(tag)
+ let tagActions = targetItems.map { item -> TagAction in
+ var tags = item.tags
+ if let index = tags.firstIndex(where: { normalizedTagName($0) == normalized }) {
+ tags.remove(at: index)
+ } else {
+ tags.append(tag)
+ }
+ return TagAction(path: item.url.path, before: item.tags, after: tags)
}
+ if fileOperationsAsynchronously {
+ guard let record = beginFileOperation(
+ kind: .tags,
+ summary: "修改 \(targetItems.count) 个项目的标签",
+ itemNames: targetItems.map(\.name)
+ ) else { return }
+ startCoordinatedTags(recordID: record.id, title: "修改标签", actions: tagActions)
+ return
+ }
+ guard var record = beginFileOperation(
+ kind: .tags,
+ summary: "修改 \(targetItems.count) 个项目的标签",
+ itemNames: targetItems.map(\.name)
+ ) else { return }
do {
- try (item.url as NSURL).setResourceValue(tags, forKey: URLResourceKey.tagNamesKey)
+ for value in tagActions {
+ try writeFinderTags(value.after, to: URL(fileURLWithPath: value.path))
+ record.actions.append(.tags(value))
+ replaceOperationRecord(record)
+ }
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
refreshItems()
} catch {
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法更新标签:\(error.localizedDescription)"
}
}
func clearTags(for item: FolderItem) {
+ clearTags(for: [item])
+ }
+
+ func clearTags(for targetItems: [FolderItem]) {
+ let targets = targetItems.filter { !$0.tags.isEmpty }
+ guard realFileMutationsAllowed(), !targets.isEmpty else { return }
+ let tagActions = targets.map { TagAction(path: $0.url.path, before: $0.tags, after: []) }
+ if fileOperationsAsynchronously {
+ guard let record = beginFileOperation(
+ kind: .tags,
+ summary: "清除 \(targets.count) 个项目的标签",
+ itemNames: targets.map(\.name)
+ ) else { return }
+ startCoordinatedTags(recordID: record.id, title: "清除标签", actions: tagActions)
+ return
+ }
+ guard var record = beginFileOperation(
+ kind: .tags,
+ summary: "清除 \(targets.count) 个项目的标签",
+ itemNames: targets.map(\.name)
+ ) else { return }
do {
- try (item.url as NSURL).setResourceValue([], forKey: URLResourceKey.tagNamesKey)
+ for item in targets {
+ try writeFinderTags([], to: item.url)
+ record.actions.append(.tags(TagAction(path: item.url.path, before: item.tags, after: [])))
+ replaceOperationRecord(record)
+ }
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
refreshItems()
} catch {
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法清除标签:\(error.localizedDescription)"
}
}
@@ -619,89 +1067,642 @@ final class FolderCanvasModel: ObservableObject {
}
func duplicate(_ item: FolderItem) {
- do {
- try FileManager.default.copyItem(
- at: item.url,
- to: uniqueDestination(for: item.url, in: item.url.deletingLastPathComponent())
+ duplicate([item])
+ }
+
+ func duplicate(_ targetItems: [FolderItem]) {
+ guard realFileMutationsAllowed(), !targetItems.isEmpty else { return }
+ if fileOperationsAsynchronously {
+ let sources = targetItems.map(\.url)
+ let names = targetItems.map(\.name)
+ let repository = folderAccessRepository
+ startFilePreparation(title: "准备制作副本") {
+ await repository.duplicatePlans(for: sources)
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(plans):
+ guard let record = self.beginFileOperation(
+ kind: .duplicate,
+ summary: "制作 \(names.count) 个项目的副本",
+ itemNames: names
+ ) else { return }
+ self.startCoordinatedTransfers(recordID: record.id, title: "制作副本", plans: plans)
+ case let .failure(error):
+ self.errorMessage = "无法准备副本目标:\(error.localizedDescription)"
+ }
+ }
+ return
+ }
+ var reserved: Set = []
+ let plans = targetItems.map { item -> FileTransferPlan in
+ let destination = uniqueDestination(
+ for: item.url,
+ in: item.url.deletingLastPathComponent(),
+ excluding: reserved
+ )
+ reserved.insert(destination.path)
+ return FileTransferPlan(
+ source: item.url,
+ destination: destination,
+ move: false,
+ replacesExistingDestination: false
)
+ }
+ guard var record = beginFileOperation(
+ kind: .duplicate,
+ summary: "制作 \(targetItems.count) 个项目的副本",
+ itemNames: targetItems.map(\.name)
+ ) else { return }
+ do {
+ for plan in plans {
+ try FileManager.default.copyItem(at: plan.source, to: plan.destination)
+ record.actions.append(.materialize(MaterializeAction(destinationPath: plan.destination.path)))
+ replaceOperationRecord(record)
+ }
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
refreshItems()
+ captureCanvasMetadata(in: &record)
+ replaceOperationRecord(record)
} catch {
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法复制文件:\(error.localizedDescription)"
}
}
func compress(_ item: FolderItem) {
- guard let folderURL else { return }
- let archiveSource = folderURL
- .appendingPathComponent(item.url.deletingPathExtension().lastPathComponent)
- .appendingPathExtension("zip")
- let archiveURL = uniqueDestination(for: archiveSource, in: folderURL)
- let sourcePath = item.url.path
- let archivePath = archiveURL.path
- Task { [weak self] in
- let failure = await Task.detached { () -> String? in
- do {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
- process.arguments = ["-c", "-k", "--keepParent", sourcePath, archivePath]
- try process.run()
- process.waitUntilExit()
- return process.terminationStatus == 0 ? nil : "系统压缩命令未能完成。"
- } catch {
- return error.localizedDescription
+ compress([item])
+ }
+
+ func compress(_ targetItems: [FolderItem]) {
+ guard let folderURL, realFileMutationsAllowed(), !targetItems.isEmpty else { return }
+ if fileOperationsAsynchronously {
+ let sources = targetItems.map(\.url)
+ let names = targetItems.map(\.name)
+ let repository = folderAccessRepository
+ startFilePreparation(title: "准备压缩") {
+ await repository.compressionPlans(for: sources, destinationFolder: folderURL)
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(plans):
+ guard let record = self.beginFileOperation(
+ kind: .compress,
+ summary: "压缩 \(names.count) 个项目",
+ itemNames: names
+ ) else { return }
+ self.startCoordinatedCompressions(recordID: record.id, title: "压缩文件", plans: plans)
+ case let .failure(error):
+ self.errorMessage = "无法准备压缩目标:\(error.localizedDescription)"
}
- }.value
- if let failure {
- self?.errorMessage = "无法压缩项目:\(failure)"
- } else {
- self?.refreshItems()
}
+ return
+ }
+ var reserved: Set = []
+ let plans = targetItems.map { item -> FileCompressionPlan in
+ let requested = folderURL
+ .appendingPathComponent(item.url.deletingPathExtension().lastPathComponent)
+ .appendingPathExtension("zip")
+ let destination = uniqueDestination(for: requested, in: folderURL, excluding: reserved)
+ reserved.insert(destination.path)
+ return FileCompressionPlan(source: item.url, destination: destination)
}
+ guard let record = beginFileOperation(
+ kind: .compress,
+ summary: "压缩 \(targetItems.count) 个项目",
+ itemNames: targetItems.map(\.name)
+ ) else { return }
+ startCoordinatedCompressions(
+ recordID: record.id,
+ title: "压缩文件",
+ plans: plans
+ )
}
- func importFiles(_ urls: [URL], move: Bool = false) {
- guard let folderURL else { return }
+ func importFiles(
+ _ urls: [URL],
+ move: Bool = false,
+ conflictChoice: ConflictChoice? = nil,
+ dropPoint: CGPoint? = nil
+ ) {
+ guard let folderURL, realFileMutationsAllowed() else { return }
+ let sources = urls.filter { $0.deletingLastPathComponent().standardizedFileURL != folderURL.standardizedFileURL }
+ guard !sources.isEmpty else { return }
+ if fileOperationsAsynchronously {
+ let repository = folderAccessRepository
+ let policy = conflictChoice ?? .cancel
+ startFilePreparation(title: move ? "准备移动到空间" : "准备复制到空间") {
+ let hasConflict = await repository.containsNameConflict(
+ sources: sources,
+ destinationFolder: folderURL
+ )
+ let plans = await repository.transferPlans(
+ sources: sources,
+ destinationFolder: folderURL,
+ move: move,
+ policy: policy
+ )
+ return PreparedImport(hasConflict: hasConflict, plans: plans)
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(prepared):
+ if prepared.hasConflict, conflictChoice == nil {
+ self.presentConflict(
+ title: "目标中已有同名项目",
+ message: sources.count == 1
+ ? "“\(sources[0].lastPathComponent)”已经存在。请选择保留两者、替换现有项目或取消。"
+ : "要导入的项目中存在同名文件。本次选择将应用到所有冲突项目。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.importFiles(sources, move: move, conflictChoice: choice, dropPoint: dropPoint)
+ }
+ return
+ }
+ guard let record = self.beginFileOperation(
+ kind: move ? .moveItems : .copyItems,
+ summary: move ? "移动 \(sources.count) 个项目到空间" : "复制 \(sources.count) 个项目到空间",
+ itemNames: sources.map(\.lastPathComponent)
+ ) else { return }
+ if let dropPoint { self.pendingDropPoints[record.id] = dropPoint }
+ self.startCoordinatedTransfers(
+ recordID: record.id,
+ title: move ? "移动到空间" : "复制到空间",
+ plans: prepared.plans
+ )
+ case let .failure(error):
+ self.errorMessage = "无法准备导入:\(error.localizedDescription)"
+ }
+ }
+ return
+ }
+ let hasConflict = sources.contains {
+ FileManager.default.fileExists(atPath: folderURL.appendingPathComponent($0.lastPathComponent).path)
+ }
+ if hasConflict, conflictChoice == nil {
+ presentConflict(
+ title: "目标中已有同名项目",
+ message: sources.count == 1
+ ? "“\(sources[0].lastPathComponent)”已经存在。请选择保留两者、替换现有项目或取消。"
+ : "要导入的项目中存在同名文件。本次选择将应用到所有冲突项目。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.importFiles(sources, move: move, conflictChoice: choice, dropPoint: dropPoint)
+ }
+ return
+ }
+ let policy = conflictChoice ?? .cancel
+ guard var record = beginFileOperation(
+ kind: move ? .moveItems : .copyItems,
+ summary: move ? "移动 \(sources.count) 个项目到空间" : "复制 \(sources.count) 个项目到空间",
+ itemNames: sources.map(\.lastPathComponent)
+ ) else { return }
do {
- for source in urls where source.deletingLastPathComponent() != folderURL {
- let target = uniqueDestination(for: source, in: folderURL)
- if move { try FileManager.default.moveItem(at: source, to: target) }
- else { try FileManager.default.copyItem(at: source, to: target) }
+ for source in sources {
+ var target = folderURL.appendingPathComponent(source.lastPathComponent)
+ if FileManager.default.fileExists(atPath: target.path) {
+ if policy == .keepBoth {
+ target = uniqueDestination(for: source, in: folderURL)
+ } else if policy == .replace {
+ let trashURL = try trashItemRecordingResult(at: target)
+ record.actions.append(.discard(DiscardAction(originalPath: target.path, trashPath: trashURL.path)))
+ replaceOperationRecord(record)
+ } else {
+ throw FileOperationTransitionError.cancelled
+ }
+ }
+ if move {
+ try FileManager.default.moveItem(at: source, to: target)
+ record.actions.append(.relocate(RelocateAction(originalPath: source.path, destinationPath: target.path)))
+ } else {
+ try FileManager.default.copyItem(at: source, to: target)
+ record.actions.append(.materialize(MaterializeAction(destinationPath: target.path)))
+ }
+ replaceOperationRecord(record)
}
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
+ if let dropPoint { applyImportedPlacement(from: record.actions, near: dropPoint) }
refreshItems()
+ captureCanvasMetadata(in: &record)
+ replaceOperationRecord(record)
} catch {
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法导入文件:\(error.localizedDescription)"
}
}
+ /// 用户点击进度条取消按钮后只设置取消标记;协调器会在当前文件系统调用结束后安全回滚。
+ func cancelCurrentFileOperation() {
+ guard var progress = fileOperationProgress, progress.allowsCancellation else { return }
+ progress.isCancelling = true
+ progress.detail = "正在完成当前步骤并准备回滚…"
+ fileOperationProgress = progress
+ fileOperationTask?.cancel()
+ }
+
+ // MARK: - 后台文件操作协调
+
+ /// 目标命名和冲突探测也可能访问网络磁盘。统一显示短暂准备状态,并在结果回到主线程后
+ /// 再创建 pending 事务;准备阶段只读,不会修改真实文件。
+ private func startFilePreparation(
+ title: String,
+ work: @escaping @Sendable () async throws -> Value,
+ completion: @escaping @MainActor (Result) -> Void
+ ) {
+ let preparationID = UUID()
+ fileOperationProgress = FileOperationProgressState(
+ id: preparationID,
+ title: title,
+ detail: "正在检查目标名称和磁盘状态…",
+ completedUnitCount: 0,
+ totalUnitCount: 0,
+ isCancelling: false,
+ allowsCancellation: false
+ )
+ fileOperationTask = Task { [weak self] in
+ let result: Result
+ do { result = .success(try await work()) }
+ catch { result = .failure(error) }
+ guard let self, self.fileOperationProgress?.id == preparationID else { return }
+ self.fileOperationProgress = nil
+ self.fileOperationTask = nil
+ completion(result)
+ }
+ }
+
+ private func startCoordinatedTransfers(
+ recordID: UUID,
+ title: String,
+ plans: [FileTransferPlan]
+ ) {
+ let total = plans.reduce(0) { $0 + ($1.replacesExistingDestination ? 2 : 1) }
+ fileOperationProgress = FileOperationProgressState(
+ id: recordID,
+ title: title,
+ detail: "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: total,
+ isCancelling: false,
+ allowsCancellation: true
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let actions = try await coordinator.performTransfers(plans) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: recordID)
+ }
+ self.finishCoordinatedOperation(recordID: recordID, actions: actions)
+ } catch let failure as CoordinatedFileOperationFailure {
+ self?.finishCoordinatedOperation(recordID: recordID, failure: failure)
+ } catch {
+ self?.finishCoordinatedOperation(recordID: recordID, unexpectedError: error)
+ }
+ }
+ }
+
+ private func startCoordinatedCompressions(
+ recordID: UUID,
+ title: String,
+ plans: [FileCompressionPlan]
+ ) {
+ fileOperationProgress = FileOperationProgressState(
+ id: recordID,
+ title: title,
+ detail: "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: plans.count,
+ isCancelling: false,
+ allowsCancellation: true
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let actions = try await coordinator.performCompressions(plans) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: recordID)
+ }
+ self.finishCoordinatedOperation(recordID: recordID, actions: actions)
+ } catch let failure as CoordinatedFileOperationFailure {
+ self?.finishCoordinatedOperation(recordID: recordID, failure: failure)
+ } catch {
+ self?.finishCoordinatedOperation(recordID: recordID, unexpectedError: error)
+ }
+ }
+ }
+
+ private func startCoordinatedTrash(
+ recordID: UUID,
+ title: String,
+ urls: [URL]
+ ) {
+ fileOperationProgress = FileOperationProgressState(
+ id: recordID,
+ title: title,
+ detail: "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: urls.count,
+ isCancelling: false,
+ allowsCancellation: true
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let actions = try await coordinator.performTrash(urls) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: recordID)
+ }
+ self.finishCoordinatedOperation(recordID: recordID, actions: actions)
+ } catch let failure as CoordinatedFileOperationFailure {
+ self?.finishCoordinatedOperation(recordID: recordID, failure: failure)
+ } catch {
+ self?.finishCoordinatedOperation(recordID: recordID, unexpectedError: error)
+ }
+ }
+ }
+
+ private func startCoordinatedCreate(recordID: UUID, title: String, destination: URL) {
+ fileOperationProgress = FileOperationProgressState(
+ id: recordID,
+ title: title,
+ detail: "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: 1,
+ isCancelling: false,
+ allowsCancellation: false
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let actions = try await coordinator.createDirectory(at: destination) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: recordID)
+ }
+ self.finishCoordinatedOperation(recordID: recordID, actions: actions)
+ } catch let failure as CoordinatedFileOperationFailure {
+ self?.finishCoordinatedOperation(recordID: recordID, failure: failure)
+ } catch {
+ self?.finishCoordinatedOperation(recordID: recordID, unexpectedError: error)
+ }
+ }
+ }
+
+ private func startCoordinatedTags(recordID: UUID, title: String, actions: [TagAction]) {
+ fileOperationProgress = FileOperationProgressState(
+ id: recordID,
+ title: title,
+ detail: "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: actions.count,
+ isCancelling: false,
+ allowsCancellation: actions.count > 1
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let completed = try await coordinator.applyTags(actions) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: recordID)
+ }
+ self.finishCoordinatedOperation(recordID: recordID, actions: completed)
+ } catch let failure as CoordinatedFileOperationFailure {
+ self?.finishCoordinatedOperation(recordID: recordID, failure: failure)
+ } catch {
+ self?.finishCoordinatedOperation(recordID: recordID, unexpectedError: error)
+ }
+ }
+ }
+
+ /// 每个真实文件步骤完成后立即更新并原子写入操作记录。
+ private func applyCoordinatorEvent(
+ _ event: CoordinatedFileOperationEvent,
+ recordID: UUID
+ ) async throws {
+ guard let index = operationRecords.firstIndex(where: { $0.id == recordID }) else {
+ throw CancellationError()
+ }
+ var record = operationRecords[index]
+ switch event {
+ case let .willBegin(_, total, detail):
+ record.detail = detail
+ record.transitionDate = Date()
+ if var progress = fileOperationProgress, progress.id == recordID {
+ progress.detail = detail
+ progress.totalUnitCount = total
+ fileOperationProgress = progress
+ }
+ case let .didApply(action, completed, total):
+ record.actions.append(action)
+ record.detail = "已完成 \(completed)/\(total) 个文件步骤"
+ record.transitionDate = Date()
+ if var progress = fileOperationProgress, progress.id == recordID {
+ progress.completedUnitCount = completed
+ progress.totalUnitCount = total
+ progress.detail = record.detail ?? progress.detail
+ fileOperationProgress = progress
+ }
+ case let .rollingBack(detail):
+ record.detail = detail
+ record.transitionDate = Date()
+ if var progress = fileOperationProgress, progress.id == recordID {
+ progress.detail = detail
+ progress.isCancelling = true
+ fileOperationProgress = progress
+ }
+ }
+ operationRecords[index] = record
+ do {
+ try await persistOperationRecordNow(record)
+ } catch {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ }
+
+ private func finishCoordinatedOperation(
+ recordID: UUID,
+ actions: [ReversibleFileAction]
+ ) {
+ guard var record = operationRecords.first(where: { $0.id == recordID }) else { return }
+ record.actions = actions
+ record.state = .applied
+ record.transitionDate = Date()
+ record.detail = nil
+ if let dropPoint = pendingDropPoints.removeValue(forKey: recordID) {
+ applyImportedPlacement(from: actions, near: dropPoint)
+ }
+ // 删除/替换动作完成后立即清除画布上的旧路径;真实文件刷新随后再确认最终状态。
+ if record.kind == .trash {
+ for action in actions {
+ guard case let .discard(value) = action else { continue }
+ positions.removeValue(forKey: value.originalPath)
+ scales.removeValue(forKey: value.originalPath)
+ inboxIDs.remove(value.originalPath)
+ selectedIDs.remove(value.originalPath)
+ }
+ }
+ replaceOperationRecord(record)
+ fileOperationProgress = nil
+ fileOperationTask = nil
+ persist(makeBackup: true)
+ refreshItems()
+ }
+
+ private func finishCoordinatedOperation(
+ recordID: UUID,
+ failure: CoordinatedFileOperationFailure
+ ) {
+ guard var record = operationRecords.first(where: { $0.id == recordID }) else { return }
+ record.actions = failure.actions
+ record.displacements = failure.displacements
+ record.state = failure.rollbackSucceeded ? .failed : .unavailable
+ record.transitionDate = Date()
+ record.detail = failure.rollbackSucceeded
+ ? "\(failure.message) 已恢复本批次完成的内容。"
+ : "\(failure.message) 无法完整恢复,请在访达中核对。"
+ pendingDropPoints.removeValue(forKey: recordID)
+ replaceOperationRecord(record)
+ fileOperationProgress = nil
+ fileOperationTask = nil
+ refreshItems()
+ errorMessage = record.detail
+ refreshRecoveryCases(presentWhenNeeded: true)
+ }
+
+ private func finishCoordinatedOperation(recordID: UUID, unexpectedError: Error) {
+ guard var record = operationRecords.first(where: { $0.id == recordID }) else { return }
+ record.state = .unavailable
+ record.transitionDate = Date()
+ record.detail = "操作异常中断:\(unexpectedError.localizedDescription)。请在访达中核对。"
+ pendingDropPoints.removeValue(forKey: recordID)
+ replaceOperationRecord(record)
+ fileOperationProgress = nil
+ fileOperationTask = nil
+ refreshItems()
+ errorMessage = record.detail
+ refreshRecoveryCases(presentWhenNeeded: true)
+ }
+
func trash(_ item: FolderItem) {
+ trash([item])
+ }
+
+ func trash(_ targetItems: [FolderItem]) {
+ guard realFileMutationsAllowed(), !targetItems.isEmpty else { return }
+ let canvasItems = targetItems.enumerated().map { index, item in
+ canvasMetadata(for: item.id, actionIndex: index)
+ }
+ if fileOperationsAsynchronously {
+ guard let record = beginFileOperation(
+ kind: .trash,
+ summary: "将 \(targetItems.count) 个项目移至废纸篓",
+ itemNames: targetItems.map(\.name),
+ canvasItems: canvasItems
+ ) else { return }
+ startCoordinatedTrash(
+ recordID: record.id,
+ title: "移至废纸篓",
+ urls: targetItems.map(\.url)
+ )
+ return
+ }
+ guard var record = beginFileOperation(
+ kind: .trash,
+ summary: "将 \(targetItems.count) 个项目移至废纸篓",
+ itemNames: targetItems.map(\.name),
+ canvasItems: canvasItems
+ ) else { return }
do {
- try FileManager.default.trashItem(at: item.url, resultingItemURL: nil)
- positions.removeValue(forKey: item.id)
- scales.removeValue(forKey: item.id)
- inboxIDs.remove(item.id)
- selectedIDs.remove(item.id)
+ for item in targetItems {
+ let trashURL = try trashItemRecordingResult(at: item.url)
+ record.actions.append(.discard(DiscardAction(
+ originalPath: item.url.path,
+ trashPath: trashURL.path
+ )))
+ replaceOperationRecord(record)
+ }
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
+ for item in targetItems {
+ positions.removeValue(forKey: item.id)
+ scales.removeValue(forKey: item.id)
+ inboxIDs.remove(item.id)
+ selectedIDs.remove(item.id)
+ }
persist(makeBackup: true)
refreshItems()
} catch {
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法移至废纸篓:\(error.localizedDescription)"
}
}
func createFolder() {
- guard let folderURL else { return }
+ guard let folderURL, realFileMutationsAllowed() else { return }
+ if fileOperationsAsynchronously {
+ let repository = folderAccessRepository
+ startFilePreparation(title: "准备新建文件夹") {
+ await repository.uniqueNamedItem(in: folderURL, baseName: "新建文件夹")
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(destination):
+ let name = destination.lastPathComponent
+ guard let record = self.beginFileOperation(
+ kind: .createFolder,
+ summary: "新建“\(name)”",
+ itemNames: [name]
+ ) else { return }
+ self.startCoordinatedCreate(recordID: record.id, title: "新建文件夹", destination: destination)
+ case let .failure(error):
+ self.errorMessage = "无法准备新文件夹:\(error.localizedDescription)"
+ }
+ }
+ return
+ }
var name = "新建文件夹"
var suffix = 2
while FileManager.default.fileExists(atPath: folderURL.appendingPathComponent(name).path) {
name = "新建文件夹 \(suffix)"
suffix += 1
}
+ let destination = folderURL.appendingPathComponent(name)
+ guard var record = beginFileOperation(
+ kind: .createFolder,
+ summary: "新建“\(name)”",
+ itemNames: [name],
+ actions: [.materialize(MaterializeAction(destinationPath: destination.path))]
+ ) else { return }
do {
try FileManager.default.createDirectory(
- at: folderURL.appendingPathComponent(name),
+ at: destination,
withIntermediateDirectories: false
)
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
refreshItems()
+ captureCanvasMetadata(in: &record)
+ replaceOperationRecord(record)
} catch {
+ markOperationFailed(&record, error: error)
errorMessage = "无法新建文件夹:\(error.localizedDescription)"
}
}
@@ -718,17 +1719,105 @@ final class FolderCanvasModel: ObservableObject {
createFromTemplate(resource: "BlankPresentation", baseName: "新建 PowerPoint 演示文稿", extension: "pptx")
}
- func rename(_ item: FolderItem, to name: String) {
- guard !name.isEmpty else { return }
- let newURL = item.url.deletingLastPathComponent().appendingPathComponent(name)
+ func rename(_ item: FolderItem, to name: String, conflictChoice: ConflictChoice? = nil) {
+ let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !cleanName.isEmpty, realFileMutationsAllowed() else { return }
+ var newURL = item.url.deletingLastPathComponent().appendingPathComponent(cleanName)
guard newURL != item.url else { return }
+ if fileOperationsAsynchronously {
+ let repository = folderAccessRepository
+ let requestedURL = newURL
+ startFilePreparation(title: "准备重命名") {
+ let exists = await repository.itemExists(at: requestedURL)
+ if exists, conflictChoice == .keepBoth {
+ let unique = await repository.uniqueDestination(
+ for: requestedURL,
+ in: requestedURL.deletingLastPathComponent()
+ )
+ return PreparedRename(destination: unique, replacesExisting: false, hasConflict: true)
+ }
+ return PreparedRename(
+ destination: requestedURL,
+ replacesExisting: exists && conflictChoice == .replace,
+ hasConflict: exists
+ )
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(prepared):
+ if prepared.hasConflict, conflictChoice == nil {
+ self.presentConflict(
+ title: "名称已存在",
+ message: "“\(cleanName)”已经存在。请选择保留两者、替换现有项目或取消。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.rename(item, to: cleanName, conflictChoice: choice)
+ }
+ return
+ }
+ let actionIndex = prepared.replacesExisting ? 1 : 0
+ guard let record = self.beginFileOperation(
+ kind: .rename,
+ summary: "将“\(item.name)”重命名为“\(prepared.destination.lastPathComponent)”",
+ itemNames: [item.name, prepared.destination.lastPathComponent],
+ canvasItems: [self.canvasMetadata(for: item.id, actionIndex: actionIndex)]
+ ) else { return }
+ self.startCoordinatedTransfers(
+ recordID: record.id,
+ title: "重命名",
+ plans: [FileTransferPlan(
+ source: item.url,
+ destination: prepared.destination,
+ move: true,
+ replacesExistingDestination: prepared.replacesExisting
+ )]
+ )
+ case let .failure(error):
+ self.errorMessage = "无法准备重命名:\(error.localizedDescription)"
+ }
+ }
+ return
+ }
+ if FileManager.default.fileExists(atPath: newURL.path), conflictChoice == nil {
+ presentConflict(
+ title: "名称已存在",
+ message: "“\(cleanName)”已经存在。请选择保留两者、替换现有项目或取消。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.rename(item, to: cleanName, conflictChoice: choice)
+ }
+ return
+ }
+ var actions: [ReversibleFileAction] = []
+ if FileManager.default.fileExists(atPath: newURL.path), conflictChoice == .keepBoth {
+ newURL = uniqueDestination(for: newURL, in: newURL.deletingLastPathComponent())
+ }
+ guard var record = beginFileOperation(
+ kind: .rename,
+ summary: "将“\(item.name)”重命名为“\(newURL.lastPathComponent)”",
+ itemNames: [item.name, newURL.lastPathComponent]
+ ) else { return }
do {
+ if FileManager.default.fileExists(atPath: newURL.path) {
+ guard conflictChoice == .replace else { throw FileOperationTransitionError.cancelled }
+ let trashURL = try trashItemRecordingResult(at: newURL)
+ actions.append(.discard(DiscardAction(originalPath: newURL.path, trashPath: trashURL.path)))
+ }
try FileManager.default.moveItem(at: item.url, to: newURL)
+ actions.append(.relocate(RelocateAction(originalPath: item.url.path, destinationPath: newURL.path)))
+ record.actions = actions
+ record.canvasItems = [canvasMetadata(for: item.id, actionIndex: actions.count - 1)]
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
transferLayout(from: item.id, to: newURL.path)
if let resourceID = item.resourceID { savedCanvas.resourcePaths[resourceID] = newURL.path }
persist(makeBackup: true)
refreshItems()
} catch {
+ record.actions = actions
+ record.detail = error.localizedDescription
+ rollbackPendingOperation(&record)
errorMessage = "无法重命名:\(error.localizedDescription)"
}
}
@@ -743,7 +1832,8 @@ final class FolderCanvasModel: ObservableObject {
}
func setWallpaper(_ url: URL?) {
- captureUndoSnapshot()
+ guard canEditLayout else { return }
+ captureUndoSnapshot(summary: url == nil ? "使用系统桌面壁纸" : "更换画布壁纸")
wallpaperURL = url
persist(makeBackup: true)
}
@@ -756,6 +1846,258 @@ final class FolderCanvasModel: ObservableObject {
tag.components(separatedBy: "\n").first ?? tag
}
+ private func beginFileOperation(
+ kind: OperationKind,
+ summary: String,
+ itemNames: [String],
+ actions: [ReversibleFileAction] = [],
+ canvasItems: [OperationCanvasItem] = []
+ ) -> OperationRecord? {
+ guard realFileMutationsAllowed() else { return nil }
+ let record = OperationRecord(
+ category: .file,
+ kind: kind,
+ summary: summary,
+ itemNames: itemNames,
+ state: .pending,
+ actions: actions,
+ canvasItems: canvasItems
+ )
+ appendOperationRecord(record)
+ return operationHistoryIsBlocked ? nil : record
+ }
+
+ private func markOperationFailed(_ record: inout OperationRecord, error: Error) {
+ record.state = .failed
+ record.transitionDate = Date()
+ record.detail = error.localizedDescription
+ replaceOperationRecord(record)
+ }
+
+ private func rollbackPendingOperation(_ record: inout OperationRecord) {
+ let originalDetail = record.detail
+ guard !record.actions.isEmpty else {
+ record.state = .failed
+ record.transitionDate = Date()
+ record.detail = originalDetail ?? "操作未完成。"
+ replaceOperationRecord(record)
+ return
+ }
+ do {
+ record.state = .applied
+ record = try fileOperationEngine.transition(record, to: .undone, conflictChoice: .replace)
+ record.state = .failed
+ record.detail = originalDetail ?? "操作失败,已回滚完成的文件变更。"
+ } catch {
+ record.state = .unavailable
+ record.detail = "操作失败且无法完整回滚:\(error.localizedDescription)。请在访达中核对。"
+ }
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
+ refreshItems()
+ }
+
+ private func presentConflict(
+ title: String,
+ message: String,
+ resolution: @escaping (ConflictChoice) -> Void
+ ) {
+ pendingConflict = PendingFileConflict(title: title, message: message)
+ conflictResolution = resolution
+ }
+
+ private func transitionFileOperation(
+ id: UUID,
+ to targetState: OperationState,
+ conflictChoice: ConflictChoice
+ ) {
+ guard realFileMutationsAllowed(),
+ let record = operationRecords.first(where: { $0.id == id }) else { return }
+ if fileOperationsAsynchronously {
+ startCoordinatedTransition(
+ record: record,
+ targetState: targetState,
+ conflictChoice: conflictChoice
+ )
+ return
+ }
+ do {
+ let updated = try fileOperationEngine.transition(
+ record,
+ to: targetState,
+ conflictChoice: conflictChoice
+ )
+ applyCanvasMetadata(from: record, updated: updated, targetState: targetState)
+ replaceOperationRecord(updated)
+ refreshItems()
+ persist(makeBackup: true)
+ } catch let conflict as FileOperationConflict {
+ presentConflict(
+ title: targetState == .undone ? "撤销时发现同名项目" : "重做时发现同名项目",
+ message: "\(conflict.localizedDescription)请选择保留两者、替换现有项目或取消。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.transitionFileOperation(id: id, to: targetState, conflictChoice: choice)
+ }
+ } catch {
+ errorMessage = "无法\(targetState == .undone ? "撤销" : "重做")操作:\(error.localizedDescription)"
+ }
+ }
+
+ /// 撤销/重做也可能涉及很多真实文件,生产模式下同样离开主线程执行。
+ private func startCoordinatedTransition(
+ record: OperationRecord,
+ targetState: OperationState,
+ conflictChoice: ConflictChoice
+ ) {
+ var transitioning = record
+ transitioning.state = targetState == .undone ? .undoing : .redoing
+ transitioning.transitionDate = Date()
+ transitioning.detail = targetState == .undone ? "正在撤销文件操作" : "正在重做文件操作"
+ replaceOperationRecord(transitioning)
+
+ fileOperationProgress = FileOperationProgressState(
+ id: record.id,
+ title: targetState == .undone ? "撤销文件操作" : "重做文件操作",
+ detail: transitioning.detail ?? "正在准备…",
+ completedUnitCount: 0,
+ totalUnitCount: 1,
+ isCancelling: false,
+ allowsCancellation: false
+ )
+ let coordinator = fileOperationCoordinator
+ fileOperationTask = Task { [weak self] in
+ do {
+ guard let self else { throw CancellationError() }
+ try await self.waitForOperationJournal()
+ let updated = try await coordinator.transition(
+ record: record,
+ to: targetState,
+ conflictChoice: conflictChoice
+ ) { [weak self] event in
+ guard let self else { throw CancellationError() }
+ try await self.applyCoordinatorEvent(event, recordID: record.id)
+ }
+ self.applyCanvasMetadata(from: record, updated: updated, targetState: targetState)
+ self.replaceOperationRecord(updated)
+ self.fileOperationProgress = nil
+ self.fileOperationTask = nil
+ self.refreshItems()
+ self.persist(makeBackup: true)
+ } catch let conflict as FileOperationConflict {
+ guard let self else { return }
+ self.replaceOperationRecord(record)
+ self.fileOperationProgress = nil
+ self.fileOperationTask = nil
+ self.presentConflict(
+ title: targetState == .undone ? "撤销时发现同名项目" : "重做时发现同名项目",
+ message: "\(conflict.localizedDescription)请选择保留两者、替换现有项目或取消。"
+ ) { [weak self] choice in
+ guard choice != .cancel else { return }
+ self?.transitionFileOperation(id: record.id, to: targetState, conflictChoice: choice)
+ }
+ } catch {
+ guard let self else { return }
+ var unavailable = record
+ unavailable.state = .unavailable
+ unavailable.transitionDate = Date()
+ unavailable.detail = "文件操作未能确认完成:\(error.localizedDescription)。请在访达中核对。"
+ self.replaceOperationRecord(unavailable)
+ self.fileOperationProgress = nil
+ self.fileOperationTask = nil
+ self.refreshItems()
+ self.errorMessage = unavailable.detail
+ }
+ }
+ }
+
+ private func canvasMetadata(for path: String, actionIndex: Int) -> OperationCanvasItem {
+ OperationCanvasItem(
+ actionIndex: actionIndex,
+ position: positions[path],
+ scale: scales[path],
+ wasInInbox: inboxIDs.contains(path)
+ )
+ }
+
+ /// 文件已经成功进入目标目录后,再把最终目标路径放到鼠标落点附近。
+ /// 失败批次不会污染画布布局;已有图标由纯布局引擎作为占用区,只读不重排。
+ private func applyImportedPlacement(from actions: [ReversibleFileAction], near point: CGPoint) {
+ let importedPaths = actions.compactMap { action -> String? in
+ switch action {
+ case let .relocate(value): value.destinationPath
+ case let .materialize(value): value.destinationPath
+ case .discard, .tags: nil
+ }
+ }
+ guard !importedPaths.isEmpty else { return }
+ let importedItems = importedPaths.map {
+ CanvasLayoutItem(id: $0, scale: scales[$0] ?? defaultIconScale)
+ }
+ let result = layoutEngine.placeImportedItems(
+ importedItems,
+ near: point,
+ existingItems: layoutItems(items),
+ positions: positions,
+ inboxIDs: inboxIDs,
+ canvasSize: canvasSize
+ )
+ positions = result.positions
+ inboxIDs = result.inboxIDs
+ persist(makeBackup: true)
+ }
+
+ private func captureCanvasMetadata(in record: inout OperationRecord) {
+ var captured: [OperationCanvasItem] = []
+ for index in record.actions.indices {
+ guard let path = activePath(for: record.actions[index], state: .applied),
+ items.contains(where: { $0.id == path }) else { continue }
+ captured.append(canvasMetadata(for: path, actionIndex: index))
+ }
+ record.canvasItems = captured
+ }
+
+ private func activePath(for action: ReversibleFileAction, state: OperationState) -> String? {
+ switch action {
+ case let .relocate(value):
+ return state == .undone ? value.originalPath : value.destinationPath
+ case let .materialize(value):
+ return state == .applied ? value.destinationPath : nil
+ case let .discard(value):
+ return state == .undone ? value.originalPath : nil
+ case let .tags(value):
+ return value.path
+ }
+ }
+
+ private func applyCanvasMetadata(
+ from previous: OperationRecord,
+ updated: OperationRecord,
+ targetState: OperationState
+ ) {
+ for index in previous.actions.indices {
+ if let oldPath = activePath(for: previous.actions[index], state: previous.state) {
+ positions.removeValue(forKey: oldPath)
+ scales.removeValue(forKey: oldPath)
+ inboxIDs.remove(oldPath)
+ selectedIDs.remove(oldPath)
+ }
+ }
+ for metadata in updated.canvasItems {
+ guard updated.actions.indices.contains(metadata.actionIndex),
+ let path = activePath(for: updated.actions[metadata.actionIndex], state: targetState) else { continue }
+ if let position = metadata.position { positions[path] = position }
+ if let scale = metadata.scale { scales[path] = scale }
+ if metadata.wasInInbox { inboxIDs.insert(path) }
+ }
+ }
+
+ private func trashItemRecordingResult(at url: URL) throws -> URL {
+ try fileOperationEngine.moveToTrash(url)
+ }
+
+ // MARK: - 布局与操作历史持久化
+
private func loadSavedCanvas() {
savedCanvas = SavedCanvas(rootResourceID: rootResourceID)
positions = [:]
@@ -825,7 +2167,7 @@ final class FolderCanvasModel: ObservableObject {
}
private func persist(makeBackup: Bool) {
- guard !layoutIsBlocked, let canvasKey else { return }
+ guard !layoutIsBlocked, !sessionIsReadOnly, let canvasKey else { return }
syncSavedCanvas()
do {
try layoutStore.save(savedCanvas, canvasKey: canvasKey, makeBackup: makeBackup)
@@ -835,17 +2177,367 @@ final class FolderCanvasModel: ObservableObject {
}
}
- private func captureUndoSnapshot() {
+ @discardableResult
+ private func captureUndoSnapshot(summary: String = "调整画布布局") -> UUID {
syncSavedCanvas()
- undoStack.append(savedCanvas)
- if undoStack.count > maximumUndoDepth { undoStack.removeFirst() }
- redoStack = []
+ invalidateRedoHistory()
+ let record = OperationRecord(
+ category: .layout,
+ kind: .layout,
+ summary: summary,
+ state: .applied
+ )
+ appendOperationRecord(record, invalidatingRedo: false)
+ undoStack.append(LayoutHistoryEntry(
+ canvas: savedCanvas,
+ operationID: record.id,
+ transitionDate: record.transitionDate
+ ))
+ if undoStack.count > maximumUndoDepth {
+ let dropped = undoStack.removeFirst()
+ setOperationState(
+ id: dropped.operationID,
+ state: .viewOnly,
+ detail: "布局撤销深度已达上限;操作记录仍可查看。"
+ )
+ }
updateUndoAvailability()
+ return record.id
}
private func updateUndoAvailability() {
- canUndo = !undoStack.isEmpty
- canRedo = !redoStack.isEmpty
+ canUndo = !undoStack.isEmpty || operationRecords.contains { $0.isFileReversible && $0.state == .applied }
+ canRedo = !redoStack.isEmpty || operationRecords.contains { $0.isFileReversible && $0.state == .undone }
+ }
+
+ private func loadOperationHistory() {
+ operationRecords = []
+ recoveryCases = []
+ isRecoveryWizardPresented = false
+ operationHistoryIsBlocked = false
+ isLoadingOperationHistory = false
+ guard let canvasKey else { return }
+ if fileOperationsAsynchronously {
+ isLoadingOperationHistory = true
+ let journal = operationJournal
+ operationHistoryLoadTask = Task { [weak self] in
+ do {
+ let document = try await journal.load(canvasKey: canvasKey)
+ guard let self, self.canvasKey == canvasKey, !Task.isCancelled else { return }
+ self.isLoadingOperationHistory = false
+ self.applyLoadedOperationHistory(document)
+ } catch {
+ guard let self, self.canvasKey == canvasKey, !Task.isCancelled else { return }
+ self.isLoadingOperationHistory = false
+ self.operationHistoryIsBlocked = true
+ self.errorMessage = error.localizedDescription
+ }
+ }
+ return
+ }
+ do {
+ // 首次读取会把 2.4 的单文件 JSON 原样迁移为 2.5 快照 + 空增量日志。
+ let document = try OperationJournalDiskStore(legacyStore: operationStore)
+ .load(canvasKey: canvasKey)
+ applyLoadedOperationHistory(document)
+ } catch {
+ operationHistoryIsBlocked = true
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func applyLoadedOperationHistory(_ loadedDocument: OperationHistoryDocument) {
+ var document = loadedDocument
+ var changed = remapOperationPathsToCurrentFolder(in: &document)
+ for index in document.records.indices {
+ if [.pending, .undoing, .redoing].contains(document.records[index].state) {
+ document.records[index].state = .unavailable
+ document.records[index].transitionDate = Date()
+ document.records[index].detail = "上次操作未能确认完成,请在访达中核对文件。"
+ changed = true
+ } else if document.records[index].category == .layout,
+ document.records[index].state == .applied || document.records[index].state == .undone {
+ document.records[index].state = .viewOnly
+ document.records[index].detail = "布局快照未跨重启保留;操作记录仍可查看。"
+ changed = true
+ }
+ }
+ operationRecords = document.records
+ if changed, !sessionIsReadOnly { enqueueOperationJournalUpsert(document.records) }
+ updateUndoAvailability()
+ refreshRecoveryCases(presentWhenNeeded: changed)
+ }
+
+ private func remapOperationPathsToCurrentFolder(in document: inout OperationHistoryDocument) -> Bool {
+ guard let folderURL else { return false }
+ let currentFolder = folderURL.standardizedFileURL
+ var changed = false
+
+ func pathInCurrentFolder(_ path: String) -> String {
+ currentFolder.appendingPathComponent(URL(fileURLWithPath: path).lastPathComponent).path
+ }
+
+ for recordIndex in document.records.indices {
+ for actionIndex in document.records[recordIndex].actions.indices {
+ switch document.records[recordIndex].actions[actionIndex] {
+ case var .relocate(value):
+ let oldCanvasParent = URL(fileURLWithPath: value.destinationPath).deletingLastPathComponent().standardizedFileURL
+ if oldCanvasParent != currentFolder {
+ value.destinationPath = pathInCurrentFolder(value.destinationPath)
+ if URL(fileURLWithPath: value.originalPath).deletingLastPathComponent().standardizedFileURL == oldCanvasParent {
+ value.originalPath = pathInCurrentFolder(value.originalPath)
+ }
+ document.records[recordIndex].actions[actionIndex] = .relocate(value)
+ changed = true
+ }
+ case var .materialize(value):
+ if URL(fileURLWithPath: value.destinationPath).deletingLastPathComponent().standardizedFileURL != currentFolder {
+ value.destinationPath = pathInCurrentFolder(value.destinationPath)
+ document.records[recordIndex].actions[actionIndex] = .materialize(value)
+ changed = true
+ }
+ case var .discard(value):
+ if URL(fileURLWithPath: value.originalPath).deletingLastPathComponent().standardizedFileURL != currentFolder {
+ value.originalPath = pathInCurrentFolder(value.originalPath)
+ document.records[recordIndex].actions[actionIndex] = .discard(value)
+ changed = true
+ }
+ case var .tags(value):
+ if URL(fileURLWithPath: value.path).deletingLastPathComponent().standardizedFileURL != currentFolder {
+ value.path = pathInCurrentFolder(value.path)
+ document.records[recordIndex].actions[actionIndex] = .tags(value)
+ changed = true
+ }
+ }
+ }
+ for displacementIndex in document.records[recordIndex].displacements.indices {
+ let old = document.records[recordIndex].displacements[displacementIndex].originalPath
+ if URL(fileURLWithPath: old).deletingLastPathComponent().standardizedFileURL != currentFolder {
+ document.records[recordIndex].displacements[displacementIndex].originalPath = pathInCurrentFolder(old)
+ changed = true
+ }
+ }
+ }
+ return changed
+ }
+
+ // MARK: - 异常恢复向导
+
+ func refreshRecoveryCases(presentWhenNeeded: Bool = false) {
+ let candidates = operationRecords.filter {
+ [.pending, .undoing, .redoing, .unavailable].contains($0.state)
+ }
+ guard !candidates.isEmpty else {
+ recoveryCases = []
+ isRecoveryWizardPresented = false
+ return
+ }
+ let analyzer = recoveryAnalyzer
+ let analyzedCanvasKey = canvasKey
+ Task { [weak self] in
+ let cases = await analyzer.analyze(records: candidates)
+ guard let self, self.canvasKey == analyzedCanvasKey else { return }
+ self.recoveryCases = cases
+ if presentWhenNeeded, !cases.isEmpty { self.isRecoveryWizardPresented = true }
+ }
+ }
+
+ /// 用户确认的是“日志解释”,不是让 App 再次移动真实文件。只有证据明确支持的完成/回滚
+ /// 结果可以直接确认;证据混合时仍允许仅存档,避免永久阻塞后续操作。
+ func resolveRecovery(_ recoveryCase: RecoveryCase, as outcome: RecoveryOutcome) {
+ guard let index = operationRecords.firstIndex(where: { $0.id == recoveryCase.recordID }) else { return }
+ if outcome == .applied, recoveryCase.suggestedOutcome != .applied {
+ errorMessage = "当前证据不足以确认操作已完成,请先在访达核对或仅存档记录。"
+ return
+ }
+ if outcome == .undone, recoveryCase.suggestedOutcome != .undone {
+ errorMessage = "当前证据不足以确认操作已回滚,请先在访达核对或仅存档记录。"
+ return
+ }
+ switch outcome {
+ case .applied:
+ operationRecords[index].state = .applied
+ operationRecords[index].detail = "已通过 2.5 异常恢复向导核对为完成。"
+ case .undone:
+ operationRecords[index].state = .undone
+ operationRecords[index].detail = "已通过 2.5 异常恢复向导核对为回滚。"
+ case .archived, .manualReview:
+ operationRecords[index].state = .archived
+ operationRecords[index].detail = "用户已核对并存档异常记录;App 未修改任何真实文件。"
+ }
+ operationRecords[index].transitionDate = Date()
+ enqueueOperationJournalUpsert([operationRecords[index]])
+ updateUndoAvailability()
+ refreshRecoveryCases()
+ }
+
+ func revealRecoveryFolder() {
+ guard let folderURL else { return }
+ NSWorkspace.shared.open(folderURL)
+ }
+
+ private func persistOperationHistory() {
+ enqueueOperationJournalReplaceAll(operationRecords)
+ }
+
+ private func enqueueOperationJournalUpsert(_ records: [OperationRecord]) {
+ guard !records.isEmpty,
+ !operationHistoryIsBlocked,
+ !sessionIsReadOnly,
+ let canvasKey else { return }
+ if operationRecords.count > 200 {
+ operationRecords = Array(operationRecords.suffix(200))
+ }
+ if !fileOperationsAsynchronously {
+ do {
+ _ = try operationJournalDiskStore.append(.upsert(records), canvasKey: canvasKey)
+ } catch {
+ blockOperationHistory(after: error)
+ }
+ return
+ }
+ let journal = operationJournal
+ let previous = journalWriteTask
+ journalWriteTask = Task { [weak self] in
+ _ = await previous?.value
+ guard let self, !self.operationHistoryIsBlocked else { return }
+ do {
+ _ = try await journal.upsert(records, canvasKey: canvasKey)
+ } catch {
+ if self.canvasKey == canvasKey { self.blockOperationHistory(after: error) }
+ }
+ }
+ }
+
+ private func enqueueOperationJournalReplaceAll(_ records: [OperationRecord]) {
+ guard !operationHistoryIsBlocked,
+ !sessionIsReadOnly,
+ let canvasKey else { return }
+ if !fileOperationsAsynchronously {
+ do {
+ _ = try operationJournalDiskStore.append(.replaceAll(records), canvasKey: canvasKey)
+ } catch {
+ blockOperationHistory(after: error)
+ }
+ return
+ }
+ let journal = operationJournal
+ let previous = journalWriteTask
+ journalWriteTask = Task { [weak self] in
+ _ = await previous?.value
+ guard let self, !self.operationHistoryIsBlocked else { return }
+ do {
+ _ = try await journal.replaceAll(records, canvasKey: canvasKey)
+ } catch {
+ if self.canvasKey == canvasKey { self.blockOperationHistory(after: error) }
+ }
+ }
+ }
+
+ /// 文件系统动作调用此方法作为写前屏障:pending 事件真正落盘之后,才能修改真实文件。
+ private func persistOperationRecordNow(_ record: OperationRecord) async throws {
+ guard !operationHistoryIsBlocked, !sessionIsReadOnly, let canvasKey else {
+ throw CocoaError(.fileWriteNoPermission)
+ }
+ _ = await journalWriteTask?.value
+ do {
+ _ = try await operationJournal.upsert(record, canvasKey: canvasKey)
+ } catch {
+ blockOperationHistory(after: error)
+ throw error
+ }
+ }
+
+ private func waitForOperationJournal() async throws {
+ _ = await journalWriteTask?.value
+ if operationHistoryIsBlocked { throw CocoaError(.fileWriteUnknown) }
+ }
+
+ private func blockOperationHistory(after error: Error) {
+ operationHistoryIsBlocked = true
+ errorMessage = "无法保存操作记录:\(error.localizedDescription)。真实文件修改已被阻止。"
+ }
+
+ private func appendOperationRecord(_ record: OperationRecord, invalidatingRedo: Bool = true) {
+ if invalidatingRedo { invalidateRedoHistory() }
+ operationRecords.append(record)
+ enqueueOperationJournalUpsert([record])
+ updateUndoAvailability()
+ }
+
+ private func replaceOperationRecord(_ record: OperationRecord) {
+ guard let index = operationRecords.firstIndex(where: { $0.id == record.id }) else { return }
+ operationRecords[index] = record
+ enqueueOperationJournalUpsert([record])
+ updateUndoAvailability()
+ }
+
+ private func setOperationState(id: UUID, state: OperationState, transitionDate: Date = Date(), detail: String? = nil) {
+ guard let index = operationRecords.firstIndex(where: { $0.id == id }) else { return }
+ operationRecords[index].state = state
+ operationRecords[index].transitionDate = transitionDate
+ operationRecords[index].detail = detail
+ enqueueOperationJournalUpsert([operationRecords[index]])
+ }
+
+ private func invalidateRedoHistory() {
+ let invalidatedIDs = Set(redoStack.map(\.operationID))
+ redoStack = []
+ for index in operationRecords.indices where
+ operationRecords[index].state == .undone || invalidatedIDs.contains(operationRecords[index].id) {
+ operationRecords[index].state = .superseded
+ operationRecords[index].transitionDate = Date()
+ }
+ let changedRecords = operationRecords.filter { $0.state == .superseded }
+ if !invalidatedIDs.isEmpty || !changedRecords.isEmpty {
+ enqueueOperationJournalUpsert(changedRecords)
+ }
+ }
+
+ private func realFileMutationsAllowed() -> Bool {
+ guard !isFileOperationInProgress else {
+ errorMessage = "已有文件操作正在进行,请等待完成或先取消。"
+ return false
+ }
+ guard !sessionIsReadOnly else {
+ errorMessage = "这个空间正在被另一个空间文件夹进程使用。当前窗口为只读模式,请先退出占用它的旧版本。"
+ return false
+ }
+ guard !isLoadingOperationHistory else {
+ errorMessage = "正在后台读取当前空间的操作记录,请稍后再修改文件。"
+ return false
+ }
+ guard !operationHistoryIsBlocked else {
+ errorMessage = "操作记录需要修复。为了避免真实文件不可恢复,当前暂停新建、改名、覆盖和删除。"
+ return false
+ }
+ return true
+ }
+
+ // MARK: - 会话写入权和布局修复
+
+ /// 尝试取得画布独占写入权。失败时仍允许浏览和打开文件,但所有写操作都被模型层阻止。
+ private func acquireSessionLock() {
+ guard sessionLockingEnabled, let canvasKey else { return }
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development"
+ do {
+ switch try CanvasSessionLock.acquire(
+ canvasKey: canvasKey,
+ directory: sessionLockDirectory,
+ appVersion: version
+ ) {
+ case let .acquired(lock):
+ sessionLock = lock
+ case let .occupied(owner):
+ sessionIsReadOnly = true
+ sessionLockOwner = owner
+ let ownerText = owner.map { "(进程 \($0.processID),版本 \($0.appVersion))" } ?? ""
+ errorMessage = "这个空间已被另一个空间文件夹占用\(ownerText)。当前以只读方式打开。"
+ }
+ } catch {
+ sessionIsReadOnly = true
+ errorMessage = "无法取得空间写入锁,已切换为只读模式:\(error.localizedDescription)"
+ }
}
private func updateBackupCount() {
@@ -882,31 +2574,29 @@ final class FolderCanvasModel: ObservableObject {
if selectedIDs.remove(oldID) != nil { selectedIDs.insert(newID) }
}
+ // MARK: - 自动放置、容量与坐标算法
+
private func arrangeInitialItems(_ freshItems: [FolderItem]) {
- positions = [:]
- inboxIDs = []
- for (index, item) in freshItems.enumerated() {
- if index < mainCanvasCapacity {
- positions[item.id] = gridPoint(for: index, scale: scale(for: item))
- } else {
- inboxIDs.insert(item.id)
- }
- }
+ let result = layoutEngine.initialLayout(
+ for: layoutItems(freshItems),
+ canvasSize: canvasSize
+ )
+ positions = result.positions
+ inboxIDs = result.inboxIDs
}
private func assignPositionsToNewItems(_ freshItems: [FolderItem]) -> Bool {
- var changed = false
- var activeItems = freshItems.filter { !inboxIDs.contains($0.id) && positions[$0.id] != nil }
- for item in freshItems where positions[item.id] == nil && !inboxIDs.contains(item.id) {
- if activeItems.count < mainCanvasCapacity,
- let point = nextAvailableGridPoint(for: item, among: activeItems) {
- positions[item.id] = point
- activeItems.append(item)
- } else {
- inboxIDs.insert(item.id)
- }
- changed = true
- }
+ let previousPositions = positions
+ let previousInbox = inboxIDs
+ let result = layoutEngine.assignNewItems(
+ layoutItems(freshItems),
+ positions: positions,
+ inboxIDs: inboxIDs,
+ canvasSize: canvasSize
+ )
+ positions = result.positions
+ inboxIDs = result.inboxIDs
+ let changed = previousPositions != positions || previousInbox != inboxIDs
return changed
}
@@ -942,58 +2632,24 @@ final class FolderCanvasModel: ObservableObject {
return changed
}
- private func nextAvailableGridPoint(for item: FolderItem, among activeItems: [FolderItem]) -> CanvasPoint? {
- let occupiedRects = activeItems.compactMap { active -> CGRect? in
- guard let position = positions[active.id] else { return nil }
- return iconRect(at: position, scale: scale(for: active))
- }
- for index in 0.. CanvasPoint {
- let column = index % initialColumns
- let row = index / initialColumns
- let cellWidth = canvasSize.width / CGFloat(initialColumns)
- let cellHeight = canvasSize.height / CGFloat(initialColumns)
- let point = CGPoint(
- x: cellWidth * (CGFloat(column) + 0.5),
- y: cellHeight * (CGFloat(row) + 0.5)
- )
- return snapped(point, scale: scale)
+ layoutEngine.gridPoint(for: index, scale: scale, canvasSize: canvasSize)
}
private func snapped(_ point: CGPoint, scale: CGFloat) -> CanvasPoint {
- let halfWidth = max(grid, 52 * scale)
- let halfHeight = max(grid, 48 * scale)
- let maximumX = max(halfWidth, canvasSize.width - halfWidth)
- let maximumY = max(halfHeight, canvasSize.height - halfHeight)
- let snappedX = (point.x / grid).rounded() * grid
- let snappedY = (point.y / grid).rounded() * grid
- return CanvasPoint(
- x: min(maximumX, max(halfWidth, snappedX)),
- y: min(maximumY, max(halfHeight, snappedY))
- )
+ layoutEngine.snapped(point, scale: scale, canvasSize: canvasSize)
}
private func iconRect(at point: CanvasPoint, scale: CGFloat) -> CGRect {
- let halfWidth = 52 * scale
- let halfHeight = 48 * scale
- return CGRect(
- x: point.x - halfWidth,
- y: point.y - halfHeight,
- width: halfWidth * 2,
- height: halfHeight * 2
- )
+ layoutEngine.iconRect(at: point, scale: scale)
}
private func isInsideBounds(_ point: CanvasPoint, scale: CGFloat) -> Bool {
- let rect = iconRect(at: point, scale: scale)
- return rect.minX >= 0 && rect.minY >= 0 && rect.maxX <= canvasSize.width && rect.maxY <= canvasSize.height
+ layoutEngine.isInsideBounds(point, scale: scale, canvasSize: canvasSize)
+ }
+
+ private func layoutItems(_ sourceItems: [FolderItem]) -> [CanvasLayoutItem] {
+ sourceItems.map { CanvasLayoutItem(id: $0.id, scale: scale(for: $0)) }
}
private func resizeLogicalCanvas(to newSize: CGSize) {
@@ -1030,6 +2686,8 @@ final class FolderCanvasModel: ObservableObject {
return "\(volumeID)|\(String(describing: fileID))"
}
+ // MARK: - 书签恢复、最近空间与文件夹监听
+
private func makeBookmark(for folder: URL) -> Data? {
try? folder.bookmarkData(
options: [],
@@ -1114,13 +2772,6 @@ final class FolderCanvasModel: ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: workItem)
}
- private func cachedIcon(for url: URL) -> NSImage {
- if let icon = iconCache[url.path] { return icon }
- let icon = NSWorkspace.shared.icon(forFile: url.path)
- iconCache[url.path] = icon
- return icon
- }
-
private func remember(folder: URL) {
recentFolders.removeAll { $0.standardizedFileURL == folder.standardizedFileURL }
recentFolders.insert(folder.standardizedFileURL, at: 0)
@@ -1160,12 +2811,23 @@ final class FolderCanvasModel: ObservableObject {
return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue
}
+ // MARK: - 文件名冲突和 Office 模板
+
private func uniqueDestination(for source: URL, in folder: URL) -> URL {
+ uniqueDestination(for: source, in: folder, excluding: [])
+ }
+
+ /// 除了磁盘已有项目,还排除同一批次已经预订的目标路径。
+ private func uniqueDestination(
+ for source: URL,
+ in folder: URL,
+ excluding reservedPaths: Set
+ ) -> URL {
let extensionName = source.pathExtension
let baseName = source.deletingPathExtension().lastPathComponent
var index = 1
var target = folder.appendingPathComponent(source.lastPathComponent)
- while FileManager.default.fileExists(atPath: target.path) {
+ while FileManager.default.fileExists(atPath: target.path) || reservedPaths.contains(target.path) {
index += 1
target = folder.appendingPathComponent("\(baseName) \(index)").appendingPathExtension(extensionName)
}
@@ -1173,21 +2835,66 @@ final class FolderCanvasModel: ObservableObject {
}
private func createFromTemplate(resource: String, baseName: String, extension fileExtension: String) {
- guard let folderURL else { return }
+ guard let folderURL, realFileMutationsAllowed() else { return }
guard let templateURL = Bundle.module.url(forResource: resource, withExtension: fileExtension) else {
errorMessage = "找不到内置 \(fileExtension.uppercased()) 模板。"
return
}
+ if fileOperationsAsynchronously {
+ let repository = folderAccessRepository
+ startFilePreparation(title: "准备新建文档") {
+ await repository.uniqueNamedItem(
+ in: folderURL,
+ baseName: baseName,
+ fileExtension: fileExtension
+ )
+ } completion: { [weak self] result in
+ guard let self else { return }
+ switch result {
+ case let .success(targetURL):
+ guard let record = self.beginFileOperation(
+ kind: .createDocument,
+ summary: "新建“\(targetURL.lastPathComponent)”",
+ itemNames: [targetURL.lastPathComponent]
+ ) else { return }
+ self.startCoordinatedTransfers(
+ recordID: record.id,
+ title: "新建文档",
+ plans: [FileTransferPlan(
+ source: templateURL,
+ destination: targetURL,
+ move: false,
+ replacesExistingDestination: false
+ )]
+ )
+ case let .failure(error):
+ self.errorMessage = "无法准备新文档:\(error.localizedDescription)"
+ }
+ }
+ return
+ }
var index = 1
var targetURL = folderURL.appendingPathComponent("\(baseName).\(fileExtension)")
while FileManager.default.fileExists(atPath: targetURL.path) {
index += 1
targetURL = folderURL.appendingPathComponent("\(baseName) \(index).\(fileExtension)")
}
+ guard var record = beginFileOperation(
+ kind: .createDocument,
+ summary: "新建“\(targetURL.lastPathComponent)”",
+ itemNames: [targetURL.lastPathComponent],
+ actions: [.materialize(MaterializeAction(destinationPath: targetURL.path))]
+ ) else { return }
do {
try FileManager.default.copyItem(at: templateURL, to: targetURL)
+ record.state = .applied
+ record.transitionDate = Date()
+ replaceOperationRecord(record)
refreshItems()
+ captureCanvasMetadata(in: &record)
+ replaceOperationRecord(record)
} catch {
+ markOperationFailed(&record, error: error)
errorMessage = "无法新建文件:\(error.localizedDescription)"
}
}
diff --git a/Sources/SpatialFolder/FolderScanService.swift b/Sources/SpatialFolder/FolderScanService.swift
new file mode 100644
index 0000000..04f7235
--- /dev/null
+++ b/Sources/SpatialFolder/FolderScanService.swift
@@ -0,0 +1,61 @@
+import Foundation
+
+/// 后台扫描得到的轻量文件信息。这里故意不包含 `NSImage`,避免把 AppKit 图像跨线程传递。
+struct ScannedFolderEntry: Equatable, Sendable {
+ let url: URL
+ let tags: [String]
+ let resourceID: String?
+}
+
+/// 只负责读取所选文件夹的第一级内容,不接触布局和界面状态。
+struct FolderDirectoryScanner: Sendable {
+ func scan(folder: URL) throws -> [ScannedFolderEntry] {
+ let keys: Set = [
+ .isHiddenKey,
+ .tagNamesKey,
+ .fileResourceIdentifierKey,
+ .volumeIdentifierKey
+ ]
+ let urls = try FileManager.default.contentsOfDirectory(
+ at: folder,
+ includingPropertiesForKeys: Array(keys),
+ options: [.skipsHiddenFiles]
+ )
+
+ var entries: [ScannedFolderEntry] = []
+ entries.reserveCapacity(urls.count)
+ for url in urls {
+ try Task.checkCancellation()
+ let values = try? url.resourceValues(forKeys: keys)
+ guard values?.isHidden != true else { continue }
+ entries.append(ScannedFolderEntry(
+ url: url,
+ tags: values?.tagNames ?? [],
+ resourceID: persistentResourceIdentifier(values)
+ ))
+ }
+ return entries.sorted {
+ $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending
+ }
+ }
+
+ /// 文件系统资源标识用于在 Finder 外部改名后找回原来的画布位置。
+ private func persistentResourceIdentifier(_ values: URLResourceValues?) -> String? {
+ guard let fileID = values?.fileResourceIdentifier else { return nil }
+ let volumeID = values?.volumeIdentifier.map { String(describing: $0) } ?? "unknown-volume"
+ return "\(volumeID)|\(String(describing: fileID))"
+ }
+}
+
+/// Actor 保证多次刷新按顺序执行,并让目录 I/O 离开主线程。
+actor FolderScanService {
+ private let scanner: FolderDirectoryScanner
+
+ init(scanner: FolderDirectoryScanner = FolderDirectoryScanner()) {
+ self.scanner = scanner
+ }
+
+ func scan(folder: URL) throws -> [ScannedFolderEntry] {
+ try scanner.scan(folder: folder)
+ }
+}
diff --git a/Sources/SpatialFolder/OperationHistoryStore.swift b/Sources/SpatialFolder/OperationHistoryStore.swift
new file mode 100644
index 0000000..589198f
--- /dev/null
+++ b/Sources/SpatialFolder/OperationHistoryStore.swift
@@ -0,0 +1,636 @@
+import Darwin
+import Foundation
+
+func writeFinderTags(_ tags: [String], to url: URL) throws {
+ if tags.isEmpty {
+ try removeExtendedAttributeIfPresent("com.apple.metadata:_kMDItemUserTags", at: url)
+ try clearFinderInfoLabel(at: url)
+ } else {
+ try (url as NSURL).setResourceValue(tags, forKey: URLResourceKey.tagNamesKey)
+ }
+}
+
+private func removeExtendedAttributeIfPresent(_ name: String, at url: URL) throws {
+ let result = removexattr(url.path, name, 0)
+ if result != 0, errno != ENOATTR {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+}
+
+private func clearFinderInfoLabel(at url: URL) throws {
+ let name = "com.apple.FinderInfo"
+ let size = getxattr(url.path, name, nil, 0, 0, 0)
+ if size < 0 {
+ if errno == ENOATTR { return }
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ guard size >= 10 else { return }
+ var bytes = [UInt8](repeating: 0, count: size)
+ let readCount = bytes.withUnsafeMutableBytes { buffer in
+ getxattr(url.path, name, buffer.baseAddress, size, 0, 0)
+ }
+ guard readCount == size else {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ let flags = (UInt16(bytes[8]) << 8) | UInt16(bytes[9])
+ let clearedFlags = flags & ~UInt16(0x000E)
+ bytes[8] = UInt8((clearedFlags >> 8) & 0x00FF)
+ bytes[9] = UInt8(clearedFlags & 0x00FF)
+
+ if bytes.allSatisfy({ $0 == 0 }) {
+ try removeExtendedAttributeIfPresent(name, at: url)
+ } else {
+ let written = bytes.withUnsafeBytes { buffer in
+ setxattr(url.path, name, buffer.baseAddress, size, 0, 0)
+ }
+ if written != 0 {
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
+ }
+ }
+}
+
+enum OperationCategory: String, Codable, Sendable {
+ case file
+ case layout
+}
+
+enum OperationKind: String, Codable, CaseIterable, Sendable {
+ case layout
+ case rename
+ case trash
+ case createFolder
+ case createDocument
+ case duplicate
+ case copyItems
+ case moveItems
+ case compress
+ case tags
+
+ var title: String {
+ switch self {
+ case .layout: "画布布局"
+ case .rename: "重命名"
+ case .trash: "移至废纸篓"
+ case .createFolder: "新建文件夹"
+ case .createDocument: "新建文档"
+ case .duplicate: "制作副本"
+ case .copyItems: "复制到空间"
+ case .moveItems: "移动到空间"
+ case .compress: "压缩"
+ case .tags: "修改标签"
+ }
+ }
+}
+
+enum OperationState: String, Codable, Sendable {
+ case pending
+ case undoing
+ case redoing
+ case applied
+ case undone
+ case superseded
+ case failed
+ case unavailable
+ case archived
+ case viewOnly
+
+ var title: String {
+ switch self {
+ case .pending: "进行中"
+ case .undoing: "正在撤销"
+ case .redoing: "正在重做"
+ case .applied: "已完成"
+ case .undone: "已撤销"
+ case .superseded: "已失效"
+ case .failed: "失败"
+ case .unavailable: "需核对"
+ case .archived: "已存档"
+ case .viewOnly: "仅可查看"
+ }
+ }
+}
+
+enum ConflictChoice: Equatable, Sendable {
+ case keepBoth
+ case replace
+ case cancel
+}
+
+struct DiagnosticOperation: Codable, Equatable, Sendable {
+ var category: OperationCategory
+ var kind: OperationKind
+ var state: OperationState
+ var occurredAt: Date
+ var actionCount: Int
+ var errorType: String?
+}
+
+struct PrivacySafeDiagnostics: Codable, Equatable, Sendable {
+ static let currentSchemaVersion = 1
+
+ var schemaVersion = currentSchemaVersion
+ var generatedAt: Date
+ var appVersion: String
+ var macOSVersion: String
+ var logicalCanvasSize: CanvasDimensions
+ var currentDisplaySize: CanvasDimensions
+ var visibleItemCount: Int
+ var inboxItemCount: Int
+ var operationCount: Int
+ var recentOperations: [DiagnosticOperation]
+}
+
+struct RelocateAction: Codable, Equatable, Sendable {
+ var originalPath: String
+ var destinationPath: String
+}
+
+struct MaterializeAction: Codable, Equatable, Sendable {
+ var destinationPath: String
+ var undoTrashPath: String?
+}
+
+struct DiscardAction: Codable, Equatable, Sendable {
+ var originalPath: String
+ var trashPath: String?
+}
+
+struct TagAction: Codable, Equatable, Sendable {
+ var path: String
+ var before: [String]
+ var after: [String]
+}
+
+enum ReversibleFileAction: Codable, Equatable, Sendable {
+ case relocate(RelocateAction)
+ case materialize(MaterializeAction)
+ case discard(DiscardAction)
+ case tags(TagAction)
+}
+
+struct ConflictDisplacement: Codable, Equatable, Sendable {
+ var originalPath: String
+ var trashPath: String
+ var createdInState: OperationState
+}
+
+struct OperationCanvasItem: Codable, Equatable, Sendable {
+ var actionIndex: Int
+ var position: CanvasPoint?
+ var scale: CGFloat?
+ var wasInInbox: Bool
+}
+
+struct OperationRecord: Codable, Equatable, Identifiable, Sendable {
+ var id: UUID
+ var category: OperationCategory
+ var kind: OperationKind
+ var summary: String
+ var itemNames: [String]
+ var performedAt: Date
+ var transitionDate: Date
+ var state: OperationState
+ var actions: [ReversibleFileAction]
+ var displacements: [ConflictDisplacement]
+ var canvasItems: [OperationCanvasItem]
+ var detail: String?
+
+ init(
+ id: UUID = UUID(),
+ category: OperationCategory,
+ kind: OperationKind,
+ summary: String,
+ itemNames: [String] = [],
+ performedAt: Date = Date(),
+ transitionDate: Date? = nil,
+ state: OperationState = .pending,
+ actions: [ReversibleFileAction] = [],
+ displacements: [ConflictDisplacement] = [],
+ canvasItems: [OperationCanvasItem] = [],
+ detail: String? = nil
+ ) {
+ self.id = id
+ self.category = category
+ self.kind = kind
+ self.summary = summary
+ self.itemNames = itemNames
+ self.performedAt = performedAt
+ self.transitionDate = transitionDate ?? performedAt
+ self.state = state
+ self.actions = actions
+ self.displacements = displacements
+ self.canvasItems = canvasItems
+ self.detail = detail
+ }
+
+ var isFileReversible: Bool {
+ category == .file && !actions.isEmpty && (state == .applied || state == .undone)
+ }
+}
+
+struct OperationHistoryDocument: Codable, Equatable, Sendable {
+ static let currentVersion = 1
+
+ var version = currentVersion
+ var records: [OperationRecord] = []
+}
+
+enum OperationHistoryStoreError: LocalizedError {
+ case corruptHistory
+
+ var errorDescription: String? {
+ switch self {
+ case .corruptHistory:
+ "操作记录无法读取。原记录已保留;在修复前,真实文件修改已被阻止。"
+ }
+ }
+}
+
+struct OperationHistoryStore {
+ let directory: URL
+ let maximumRecords: Int
+
+ init(directory: URL? = nil, maximumRecords: Int = 200) {
+ if let directory {
+ self.directory = directory
+ } else {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ self.directory = base.appendingPathComponent("SpatialFolder/Operations", isDirectory: true)
+ }
+ self.maximumRecords = max(20, maximumRecords)
+ }
+
+ func historyURL(canvasKey: String) -> URL {
+ directory.appendingPathComponent(canvasKey).appendingPathExtension("json")
+ }
+
+ func load(canvasKey: String) throws -> OperationHistoryDocument {
+ let url = historyURL(canvasKey: canvasKey)
+ guard FileManager.default.fileExists(atPath: url.path) else {
+ // 测试工具和 2.4 兼容调用仍可读取已经迁移到 2.5 的快照与日志。
+ return try OperationJournalDiskStore(legacyStore: self)
+ .load(canvasKey: canvasKey, migrateLegacy: false)
+ }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ guard let document = try? decoder.decode(OperationHistoryDocument.self, from: Data(contentsOf: url)) else {
+ throw OperationHistoryStoreError.corruptHistory
+ }
+ return document
+ }
+
+ func save(_ document: OperationHistoryDocument, canvasKey: String) throws {
+ var trimmed = document
+ if trimmed.records.count > maximumRecords {
+ trimmed.records = Array(trimmed.records.suffix(maximumRecords))
+ }
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ let decoderCompatibleData = try encoder.encode(trimmed)
+ try decoderCompatibleData.write(to: historyURL(canvasKey: canvasKey), options: .atomic)
+ }
+
+ func archiveCorruptHistoryAndReset(canvasKey: String) throws -> URL {
+ let source = historyURL(canvasKey: canvasKey)
+ let corruptDirectory = directory.appendingPathComponent("Corrupt", isDirectory: true)
+ try FileManager.default.createDirectory(at: corruptDirectory, withIntermediateDirectories: true)
+ let stamp = String(format: "%020llu", DispatchTime.now().uptimeNanoseconds)
+ let archive = corruptDirectory.appendingPathComponent("\(canvasKey)-\(stamp).json")
+ if FileManager.default.fileExists(atPath: source.path) {
+ try FileManager.default.moveItem(at: source, to: archive)
+ } else {
+ try Data().write(to: archive)
+ }
+ try save(OperationHistoryDocument(), canvasKey: canvasKey)
+ return archive
+ }
+}
+
+struct FileOperationConflict: LocalizedError, Equatable, Sendable {
+ let targetPath: String
+ let operationSummary: String
+
+ var errorDescription: String? {
+ "“\(URL(fileURLWithPath: targetPath).lastPathComponent)”已经存在。"
+ }
+}
+
+enum FileOperationTransitionError: LocalizedError {
+ case invalidState
+ case missingSource(String)
+ case cancelled
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidState:
+ "这个操作当前不能撤销或重做。"
+ case let .missingSource(path):
+ "找不到“\(URL(fileURLWithPath: path).lastPathComponent)”,它可能已在其他应用中移动或删除。"
+ case .cancelled:
+ "操作已取消。"
+ }
+ }
+}
+
+/// `FileManager` 的公开文件操作可跨线程调用;本类型的配置在初始化后不再修改,
+/// 并且 2.4 的协调器保证同一画布只有一个真实文件事务执行,因此可安全传入后台 Actor。
+struct FileOperationEngine: @unchecked Sendable {
+ var fileManager: FileManager = .default
+ var trashDirectoryForTesting: URL?
+
+ func moveToTrash(_ url: URL) throws -> URL {
+ try trashItem(at: url)
+ }
+
+ func transition(
+ _ record: OperationRecord,
+ to targetState: OperationState,
+ conflictChoice: ConflictChoice
+ ) throws -> OperationRecord {
+ guard record.category == .file,
+ (record.state == .applied && targetState == .undone) ||
+ (record.state == .undone && targetState == .applied) else {
+ throw FileOperationTransitionError.invalidState
+ }
+ try preflight(record, to: targetState, conflictChoice: conflictChoice)
+ var updated = record
+ let previousState = record.state
+ let indexes = targetState == .undone
+ ? Array(updated.actions.indices.reversed())
+ : Array(updated.actions.indices)
+
+ for index in indexes {
+ updated.actions[index] = try transitionAction(
+ updated.actions[index],
+ to: targetState,
+ conflictChoice: conflictChoice,
+ summary: updated.summary,
+ displacements: &updated.displacements
+ )
+ }
+
+ var retainedDisplacements: [ConflictDisplacement] = []
+ for displacement in updated.displacements {
+ guard displacement.createdInState == previousState else {
+ retainedDisplacements.append(displacement)
+ continue
+ }
+ var destination = URL(fileURLWithPath: displacement.originalPath)
+ destination = try resolvedDestination(
+ destination,
+ choice: conflictChoice,
+ summary: updated.summary,
+ state: targetState,
+ displacements: &retainedDisplacements
+ )
+ let source = URL(fileURLWithPath: displacement.trashPath)
+ guard fileManager.fileExists(atPath: source.path) else {
+ throw FileOperationTransitionError.missingSource(source.path)
+ }
+ try fileManager.moveItem(at: source, to: destination)
+ }
+
+ updated.displacements = retainedDisplacements
+ updated.state = targetState
+ updated.transitionDate = Date()
+ updated.detail = nil
+ return updated
+ }
+
+ private func preflight(
+ _ record: OperationRecord,
+ to targetState: OperationState,
+ conflictChoice: ConflictChoice
+ ) throws {
+ let indexes = targetState == .undone
+ ? Array(record.actions.indices.reversed())
+ : Array(record.actions.indices)
+ let paths = record.actions.flatMap(actionPaths) + record.displacements.flatMap {
+ [$0.originalPath, $0.trashPath]
+ }
+ let trackedPaths = Set(paths)
+ var exists = Set(paths.filter { fileManager.fileExists(atPath: $0) })
+
+ func virtuallyExists(_ path: String) -> Bool {
+ trackedPaths.contains(path) ? exists.contains(path) : fileManager.fileExists(atPath: path)
+ }
+
+ func requireSource(_ path: String) throws {
+ guard virtuallyExists(path) else {
+ throw FileOperationTransitionError.missingSource(path)
+ }
+ }
+
+ func requireDestination(_ path: String) throws {
+ guard conflictChoice == .cancel,
+ virtuallyExists(path) else { return }
+ throw FileOperationConflict(targetPath: path, operationSummary: record.summary)
+ }
+
+ for index in indexes {
+ switch record.actions[index] {
+ case let .relocate(value):
+ let source = targetState == .undone ? value.destinationPath : value.originalPath
+ let destination = targetState == .undone ? value.originalPath : value.destinationPath
+ try requireSource(source)
+ try requireDestination(destination)
+ exists.remove(source)
+ exists.insert(destination)
+ case let .materialize(value):
+ if targetState == .undone {
+ try requireSource(value.destinationPath)
+ exists.remove(value.destinationPath)
+ } else {
+ guard let source = value.undoTrashPath else {
+ throw FileOperationTransitionError.missingSource(value.destinationPath)
+ }
+ try requireSource(source)
+ try requireDestination(value.destinationPath)
+ exists.remove(source)
+ exists.insert(value.destinationPath)
+ }
+ case let .discard(value):
+ if targetState == .undone {
+ guard let source = value.trashPath else {
+ throw FileOperationTransitionError.missingSource(value.originalPath)
+ }
+ try requireSource(source)
+ try requireDestination(value.originalPath)
+ exists.remove(source)
+ exists.insert(value.originalPath)
+ } else {
+ try requireSource(value.originalPath)
+ exists.remove(value.originalPath)
+ }
+ case let .tags(value):
+ try requireSource(value.path)
+ }
+ }
+
+ for displacement in record.displacements where displacement.createdInState == record.state {
+ try requireSource(displacement.trashPath)
+ try requireDestination(displacement.originalPath)
+ exists.remove(displacement.trashPath)
+ exists.insert(displacement.originalPath)
+ }
+ }
+
+ private func actionPaths(_ action: ReversibleFileAction) -> [String] {
+ switch action {
+ case let .relocate(value): [value.originalPath, value.destinationPath]
+ case let .materialize(value): [value.destinationPath] + (value.undoTrashPath.map { [$0] } ?? [])
+ case let .discard(value): [value.originalPath] + (value.trashPath.map { [$0] } ?? [])
+ case let .tags(value): [value.path]
+ }
+ }
+
+ private func transitionAction(
+ _ action: ReversibleFileAction,
+ to targetState: OperationState,
+ conflictChoice: ConflictChoice,
+ summary: String,
+ displacements: inout [ConflictDisplacement]
+ ) throws -> ReversibleFileAction {
+ switch action {
+ case var .relocate(value):
+ let sourcePath = targetState == .undone ? value.destinationPath : value.originalPath
+ var destination = URL(fileURLWithPath: targetState == .undone ? value.originalPath : value.destinationPath)
+ let source = URL(fileURLWithPath: sourcePath)
+ guard fileManager.fileExists(atPath: source.path) else {
+ throw FileOperationTransitionError.missingSource(source.path)
+ }
+ destination = try resolvedDestination(
+ destination,
+ choice: conflictChoice,
+ summary: summary,
+ state: targetState,
+ displacements: &displacements
+ )
+ try fileManager.moveItem(at: source, to: destination)
+ if targetState == .undone { value.originalPath = destination.path }
+ else { value.destinationPath = destination.path }
+ return .relocate(value)
+
+ case var .materialize(value):
+ let destination = URL(fileURLWithPath: value.destinationPath)
+ if targetState == .undone {
+ guard fileManager.fileExists(atPath: destination.path) else {
+ throw FileOperationTransitionError.missingSource(destination.path)
+ }
+ value.undoTrashPath = try trashItem(at: destination).path
+ } else {
+ guard let trashPath = value.undoTrashPath else {
+ throw FileOperationTransitionError.missingSource(value.destinationPath)
+ }
+ let source = URL(fileURLWithPath: trashPath)
+ guard fileManager.fileExists(atPath: source.path) else {
+ throw FileOperationTransitionError.missingSource(source.path)
+ }
+ let resolved = try resolvedDestination(
+ destination,
+ choice: conflictChoice,
+ summary: summary,
+ state: targetState,
+ displacements: &displacements
+ )
+ try fileManager.moveItem(at: source, to: resolved)
+ value.destinationPath = resolved.path
+ value.undoTrashPath = nil
+ }
+ return .materialize(value)
+
+ case var .discard(value):
+ let original = URL(fileURLWithPath: value.originalPath)
+ if targetState == .undone {
+ guard let trashPath = value.trashPath else {
+ throw FileOperationTransitionError.missingSource(value.originalPath)
+ }
+ let source = URL(fileURLWithPath: trashPath)
+ guard fileManager.fileExists(atPath: source.path) else {
+ throw FileOperationTransitionError.missingSource(source.path)
+ }
+ let destination = try resolvedDestination(
+ original,
+ choice: conflictChoice,
+ summary: summary,
+ state: targetState,
+ displacements: &displacements
+ )
+ try fileManager.moveItem(at: source, to: destination)
+ value.originalPath = destination.path
+ } else {
+ guard fileManager.fileExists(atPath: original.path) else {
+ throw FileOperationTransitionError.missingSource(original.path)
+ }
+ value.trashPath = try trashItem(at: original).path
+ }
+ return .discard(value)
+
+ case let .tags(value):
+ let url = URL(fileURLWithPath: value.path)
+ guard fileManager.fileExists(atPath: url.path) else {
+ throw FileOperationTransitionError.missingSource(url.path)
+ }
+ let tags = targetState == .undone ? value.before : value.after
+ try writeFinderTags(tags, to: url)
+ return action
+ }
+ }
+
+ private func resolvedDestination(
+ _ requested: URL,
+ choice: ConflictChoice,
+ summary: String,
+ state: OperationState,
+ displacements: inout [ConflictDisplacement]
+ ) throws -> URL {
+ guard fileManager.fileExists(atPath: requested.path) else { return requested }
+ switch choice {
+ case .cancel:
+ throw FileOperationConflict(targetPath: requested.path, operationSummary: summary)
+ case .keepBoth:
+ return uniqueDestination(for: requested)
+ case .replace:
+ let trashPath = try trashItem(at: requested).path
+ displacements.append(ConflictDisplacement(
+ originalPath: requested.path,
+ trashPath: trashPath,
+ createdInState: state
+ ))
+ return requested
+ }
+ }
+
+ func uniqueDestination(for requested: URL) -> URL {
+ let folder = requested.deletingLastPathComponent()
+ let extensionName = requested.pathExtension
+ let baseName = requested.deletingPathExtension().lastPathComponent
+ var index = 2
+ var target = requested
+ while fileManager.fileExists(atPath: target.path) {
+ let name = extensionName.isEmpty ? "\(baseName) \(index)" : "\(baseName) \(index).\(extensionName)"
+ target = folder.appendingPathComponent(name)
+ index += 1
+ }
+ return target
+ }
+
+ private func trashItem(at url: URL) throws -> URL {
+ if let trashDirectoryForTesting {
+ try fileManager.createDirectory(at: trashDirectoryForTesting, withIntermediateDirectories: true)
+ let destination = uniqueDestination(
+ for: trashDirectoryForTesting.appendingPathComponent(url.lastPathComponent)
+ )
+ try fileManager.moveItem(at: url, to: destination)
+ return destination
+ }
+ var resultingURL: NSURL?
+ try fileManager.trashItem(at: url, resultingItemURL: &resultingURL)
+ guard let result = resultingURL as URL? else {
+ throw FileOperationTransitionError.missingSource(url.path)
+ }
+ return result
+ }
+}
diff --git a/Sources/SpatialFolder/OperationJournalStore.swift b/Sources/SpatialFolder/OperationJournalStore.swift
new file mode 100644
index 0000000..9d2dcd5
--- /dev/null
+++ b/Sources/SpatialFolder/OperationJournalStore.swift
@@ -0,0 +1,340 @@
+import Foundation
+
+/// 2.5 的增量日志事件。每次状态变化只追加受影响的记录,避免重复编码整个历史文档。
+enum OperationJournalMutation: Codable, Equatable, Sendable {
+ case upsert([OperationRecord])
+ case replaceAll([OperationRecord])
+}
+
+struct OperationJournalEntry: Codable, Equatable, Sendable {
+ static let currentVersion = 1
+
+ var version = currentVersion
+ var id = UUID()
+ var writtenAt = Date()
+ var mutation: OperationJournalMutation
+}
+
+struct OperationJournalMetrics: Equatable, Sendable {
+ var appendedEventCount: Int
+ var journalByteCount: Int64
+ var didCompact: Bool
+}
+
+/// 磁盘格式仍由一个值类型负责,便于在无并发的迁移测试中直接验证;生产环境只能通过
+/// `OperationJournalStore` actor 调用它,确保同一画布的事件顺序与真实文件操作顺序一致。
+struct OperationJournalDiskStore: Sendable {
+ let directory: URL
+ let maximumRecords: Int
+ let compactionEventThreshold: Int
+ let compactionByteThreshold: Int64
+
+ init(
+ directory: URL? = nil,
+ maximumRecords: Int = 200,
+ compactionEventThreshold: Int = 500,
+ compactionByteThreshold: Int64 = 1_048_576
+ ) {
+ if let directory {
+ self.directory = directory
+ } else {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
+ self.directory = base.appendingPathComponent("SpatialFolder/Operations", isDirectory: true)
+ }
+ self.maximumRecords = max(20, maximumRecords)
+ self.compactionEventThreshold = max(2, compactionEventThreshold)
+ self.compactionByteThreshold = max(4_096, compactionByteThreshold)
+ }
+
+ init(legacyStore: OperationHistoryStore) {
+ self.init(directory: legacyStore.directory, maximumRecords: legacyStore.maximumRecords)
+ }
+
+ func snapshotURL(canvasKey: String) -> URL {
+ directory.appendingPathComponent(canvasKey).appendingPathExtension("snapshot.json")
+ }
+
+ func journalURL(canvasKey: String) -> URL {
+ directory.appendingPathComponent(canvasKey).appendingPathExtension("journal.jsonl")
+ }
+
+ func legacyURL(canvasKey: String) -> URL {
+ directory.appendingPathComponent(canvasKey).appendingPathExtension("json")
+ }
+
+ func archivedLegacyURL(canvasKey: String) -> URL {
+ directory.appendingPathComponent(canvasKey).appendingPathExtension("2.4.json")
+ }
+
+ /// 加载顺序是快照 → 增量日志。只有 2.5 文件都不存在时才读取 2.4 的单文件历史。
+ func load(canvasKey: String, migrateLegacy: Bool = true) throws -> OperationHistoryDocument {
+ let fileManager = FileManager.default
+ var document: OperationHistoryDocument
+ let snapshot = snapshotURL(canvasKey: canvasKey)
+ let legacy = legacyURL(canvasKey: canvasKey)
+
+ if fileManager.fileExists(atPath: snapshot.path) {
+ document = try decodeDocument(at: snapshot)
+ } else if fileManager.fileExists(atPath: legacy.path) {
+ document = try decodeDocument(at: legacy)
+ if migrateLegacy {
+ try migrate(document: document, canvasKey: canvasKey)
+ }
+ } else {
+ document = OperationHistoryDocument()
+ }
+
+ let journal = journalURL(canvasKey: canvasKey)
+ if fileManager.fileExists(atPath: journal.path) {
+ document = try replayJournal(at: journal, startingFrom: document)
+ }
+ document.records = trimmed(document.records)
+ return document
+ }
+
+ @discardableResult
+ func append(
+ _ mutation: OperationJournalMutation,
+ canvasKey: String,
+ knownEventCount: Int? = nil
+ ) throws -> OperationJournalMetrics {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let entry = OperationJournalEntry(mutation: mutation)
+ let encoder = makeEncoder()
+ var data = try encoder.encode(entry)
+ data.append(0x0A)
+ let url = journalURL(canvasKey: canvasKey)
+ // actor 会传入内存计数;值类型测试或修复工具首次调用时才扫描一次现有日志。
+ let previousEventCount: Int
+ if let knownEventCount {
+ previousEventCount = knownEventCount
+ } else {
+ previousEventCount = try repairTrailingPartialEventAndCount(at: url)
+ }
+
+ if !FileManager.default.fileExists(atPath: url.path) {
+ guard FileManager.default.createFile(atPath: url.path, contents: nil) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ }
+ let handle = try FileHandle(forWritingTo: url)
+ defer { try? handle.close() }
+ try handle.seekToEnd()
+ try handle.write(contentsOf: data)
+ try handle.synchronize()
+
+ let eventCount = previousEventCount + 1
+ let byteCount = Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? data.count)
+ let shouldCompact = eventCount >= compactionEventThreshold || byteCount >= compactionByteThreshold
+ if shouldCompact {
+ let document = try load(canvasKey: canvasKey, migrateLegacy: false)
+ try compact(document, canvasKey: canvasKey)
+ }
+ return OperationJournalMetrics(
+ appendedEventCount: eventCount,
+ journalByteCount: byteCount,
+ didCompact: shouldCompact
+ )
+ }
+
+ func compact(_ document: OperationHistoryDocument, canvasKey: String) throws {
+ var snapshot = document
+ snapshot.records = trimmed(snapshot.records)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try makeEncoder().encode(snapshot).write(
+ to: snapshotURL(canvasKey: canvasKey),
+ options: .atomic
+ )
+ // 快照已原子替换后才清空日志;崩溃最多造成事件被重放两次,upsert 本身是幂等的。
+ try Data().write(to: journalURL(canvasKey: canvasKey), options: .atomic)
+ }
+
+ func archiveCorruptHistoryAndReset(canvasKey: String) throws -> URL {
+ let corruptDirectory = directory.appendingPathComponent("Corrupt", isDirectory: true)
+ try FileManager.default.createDirectory(at: corruptDirectory, withIntermediateDirectories: true)
+ let stamp = String(format: "%020llu", DispatchTime.now().uptimeNanoseconds)
+ let archive = corruptDirectory.appendingPathComponent("\(canvasKey)-\(stamp)", isDirectory: true)
+ try FileManager.default.createDirectory(at: archive, withIntermediateDirectories: true)
+ for source in [
+ snapshotURL(canvasKey: canvasKey),
+ journalURL(canvasKey: canvasKey),
+ legacyURL(canvasKey: canvasKey)
+ ] where FileManager.default.fileExists(atPath: source.path) {
+ try FileManager.default.moveItem(
+ at: source,
+ to: archive.appendingPathComponent(source.lastPathComponent)
+ )
+ }
+ try compact(OperationHistoryDocument(), canvasKey: canvasKey)
+ return archive
+ }
+
+ private func migrate(document: OperationHistoryDocument, canvasKey: String) throws {
+ try compact(document, canvasKey: canvasKey)
+ let legacy = legacyURL(canvasKey: canvasKey)
+ let archive = archivedLegacyURL(canvasKey: canvasKey)
+ if FileManager.default.fileExists(atPath: archive.path) {
+ try FileManager.default.removeItem(at: archive)
+ }
+ try FileManager.default.moveItem(at: legacy, to: archive)
+ }
+
+ private func replayJournal(
+ at url: URL,
+ startingFrom document: OperationHistoryDocument
+ ) throws -> OperationHistoryDocument {
+ let data = try Data(contentsOf: url)
+ guard !data.isEmpty else { return document }
+ // 一次追加在断电时可能只留下没有换行终止的最后半行。pending
+ // 写入只有在整行 synchronize 成功后才会放行真实文件操作,因此可安全
+ // 忽略这段未提交尾部。已有换行但无法解码的事件仍视为真实损坏。
+ let committedData: Data
+ if data.last == 0x0A {
+ committedData = data
+ } else if let finalNewline = data.lastIndex(of: 0x0A) {
+ committedData = data.prefix(through: finalNewline)
+ } else {
+ committedData = Data()
+ }
+ guard !committedData.isEmpty else { return document }
+ var result = document
+ let decoder = makeDecoder()
+ for line in committedData.split(separator: 0x0A, omittingEmptySubsequences: true) {
+ guard let entry = try? decoder.decode(OperationJournalEntry.self, from: Data(line)),
+ entry.version == OperationJournalEntry.currentVersion else {
+ throw OperationHistoryStoreError.corruptHistory
+ }
+ apply(entry.mutation, to: &result)
+ }
+ result.records = trimmed(result.records)
+ return result
+ }
+
+ private func apply(_ mutation: OperationJournalMutation, to document: inout OperationHistoryDocument) {
+ switch mutation {
+ case let .upsert(records):
+ for record in records {
+ if let index = document.records.firstIndex(where: { $0.id == record.id }) {
+ document.records[index] = record
+ } else {
+ document.records.append(record)
+ }
+ }
+ case let .replaceAll(records):
+ document.records = records
+ }
+ }
+
+ private func trimmed(_ records: [OperationRecord]) -> [OperationRecord] {
+ records.count > maximumRecords ? Array(records.suffix(maximumRecords)) : records
+ }
+
+ private func decodeDocument(at url: URL) throws -> OperationHistoryDocument {
+ guard let document = try? makeDecoder().decode(
+ OperationHistoryDocument.self,
+ from: Data(contentsOf: url)
+ ) else {
+ throw OperationHistoryStoreError.corruptHistory
+ }
+ return document
+ }
+
+ /// 第一次追加时同时修剪断电遗留的半行,避免新 JSON 与旧尾部粘连。
+ private func repairTrailingPartialEventAndCount(at url: URL) throws -> Int {
+ guard FileManager.default.fileExists(atPath: url.path) else { return 0 }
+ var data = try Data(contentsOf: url)
+ if !data.isEmpty, data.last != 0x0A {
+ if let finalNewline = data.lastIndex(of: 0x0A) {
+ data = data.prefix(through: finalNewline)
+ } else {
+ data = Data()
+ }
+ try data.write(to: url, options: .atomic)
+ }
+ return data.reduce(into: 0) { count, byte in
+ if byte == 0x0A { count += 1 }
+ }
+ }
+
+ private func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ private func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+/// 生产环境的唯一日志入口。actor 串行化追加、迁移和压缩,调用方可以在修改真实文件前
+/// `await` 确认 pending 记录已经同步到磁盘。
+actor OperationJournalStore {
+ private let diskStore: OperationJournalDiskStore
+ private var eventCounts: [String: Int] = [:]
+
+ init(diskStore: OperationJournalDiskStore = OperationJournalDiskStore()) {
+ self.diskStore = diskStore
+ }
+
+ init(legacyStore: OperationHistoryStore) {
+ diskStore = OperationJournalDiskStore(legacyStore: legacyStore)
+ }
+
+ func load(canvasKey: String) throws -> OperationHistoryDocument {
+ let document = try diskStore.load(canvasKey: canvasKey)
+ eventCounts[canvasKey] = nil
+ return document
+ }
+
+ @discardableResult
+ func upsert(_ record: OperationRecord, canvasKey: String) throws -> OperationJournalMetrics {
+ try append(.upsert([record]), canvasKey: canvasKey)
+ }
+
+ @discardableResult
+ func upsert(_ records: [OperationRecord], canvasKey: String) throws -> OperationJournalMetrics {
+ try append(.upsert(records), canvasKey: canvasKey)
+ }
+
+ @discardableResult
+ func replaceAll(_ records: [OperationRecord], canvasKey: String) throws -> OperationJournalMetrics {
+ try append(.replaceAll(records), canvasKey: canvasKey)
+ }
+
+ func compact(_ document: OperationHistoryDocument, canvasKey: String) throws {
+ try diskStore.compact(document, canvasKey: canvasKey)
+ eventCounts[canvasKey] = 0
+ }
+
+ func archiveCorruptHistoryAndReset(canvasKey: String) throws -> URL {
+ let url = try diskStore.archiveCorruptHistoryAndReset(canvasKey: canvasKey)
+ eventCounts[canvasKey] = 0
+ return url
+ }
+
+ private func append(
+ _ mutation: OperationJournalMutation,
+ canvasKey: String
+ ) throws -> OperationJournalMetrics {
+ let knownCount: Int
+ if let cached = eventCounts[canvasKey] {
+ knownCount = cached
+ } else {
+ // 传 nil 时磁盘层只在本次扫描既有日志;后续追加都是 O(本事件大小)。
+ let metrics = try diskStore.append(mutation, canvasKey: canvasKey)
+ eventCounts[canvasKey] = metrics.didCompact ? 0 : metrics.appendedEventCount
+ return metrics
+ }
+ let metrics = try diskStore.append(
+ mutation,
+ canvasKey: canvasKey,
+ knownEventCount: knownCount
+ )
+ eventCounts[canvasKey] = metrics.didCompact ? 0 : metrics.appendedEventCount
+ return metrics
+ }
+}
diff --git a/Sources/SpatialFolder/PreferencesMigrator.swift b/Sources/SpatialFolder/PreferencesMigrator.swift
new file mode 100644
index 0000000..3ddbcac
--- /dev/null
+++ b/Sources/SpatialFolder/PreferencesMigrator.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+/// 记录一次偏好迁移的来源和复制内容,便于测试与后续诊断。
+struct PreferencesMigrationReport: Equatable, Sendable {
+ var sourceDomains: Set = []
+ var copiedKeys: Set = []
+}
+
+/// 把旧测试版 Bundle ID 下的用户偏好迁移到 2.4 起使用的稳定偏好域。
+///
+/// 布局与文件操作记录一直存放在固定的 Application Support 目录,不需要在这里迁移;
+/// 这里只处理 UserDefaults 中的“上次文件夹、最近空间、外观”等入口状态。
+enum PreferencesMigrator {
+ static let currentMigrationVersion = 1
+ static let migrationVersionKey = "preferencesMigrationVersion"
+
+ /// 从新到旧排列;同一个键优先采用用户最近使用过的版本。
+ static let legacyBundleIdentifiers = [
+ "local.spatialfolder.app.v232",
+ "local.spatialfolder.app.v231",
+ "local.spatialfolder.app.v23",
+ "local.spatialfolder.app.v22",
+ "local.spatialfolder.app.v20"
+ ]
+
+ static let migratedKeys = [
+ "appearanceMode",
+ "recentFolderPaths",
+ "recentFolderBookmarksV2",
+ "lastOpenedFolderBookmarkV2",
+ "lastOpenedFolderPath"
+ ]
+
+ /// App 启动时调用的便利入口。
+ @discardableResult
+ static func migrateIfNeeded(destination: UserDefaults = .standard) -> PreferencesMigrationReport {
+ let sources = legacyBundleIdentifiers.compactMap { identifier -> (String, UserDefaults)? in
+ guard let defaults = UserDefaults(suiteName: identifier) else { return nil }
+ return (identifier, defaults)
+ }
+ return migrateIfNeeded(destination: destination, sources: sources)
+ }
+
+ /// 可注入偏好存储的核心实现,避免测试依赖当前机器真实的用户设置。
+ @discardableResult
+ static func migrateIfNeeded(
+ destination: UserDefaults,
+ sources: [(name: String, defaults: UserDefaults)]
+ ) -> PreferencesMigrationReport {
+ guard destination.integer(forKey: migrationVersionKey) < currentMigrationVersion else {
+ return PreferencesMigrationReport()
+ }
+
+ var report = PreferencesMigrationReport()
+ for key in migratedKeys where destination.object(forKey: key) == nil {
+ guard let source = sources.first(where: { $0.defaults.object(forKey: key) != nil }),
+ let value = source.defaults.object(forKey: key) else { continue }
+ destination.set(value, forKey: key)
+ report.sourceDomains.insert(source.name)
+ report.copiedKeys.insert(key)
+ }
+ destination.set(currentMigrationVersion, forKey: migrationVersionKey)
+ return report
+ }
+}
diff --git a/Sources/SpatialFolder/RecoveryAnalyzer.swift b/Sources/SpatialFolder/RecoveryAnalyzer.swift
new file mode 100644
index 0000000..75371b0
--- /dev/null
+++ b/Sources/SpatialFolder/RecoveryAnalyzer.swift
@@ -0,0 +1,199 @@
+import Foundation
+
+enum RecoveryOutcome: String, Codable, Equatable, Sendable {
+ case applied
+ case undone
+ case archived
+ case manualReview
+
+ var title: String {
+ switch self {
+ case .applied: "确认已完成"
+ case .undone: "确认已回滚"
+ case .archived: "仅存档记录"
+ case .manualReview: "需要人工核对"
+ }
+ }
+}
+
+struct RecoveryEvidence: Identifiable, Equatable, Sendable {
+ let id: String
+ var itemName: String
+ var observation: String
+ var supportsApplied: Bool
+ var supportsUndone: Bool
+}
+
+struct RecoveryCase: Identifiable, Equatable, Sendable {
+ var id: UUID { recordID }
+ var recordID: UUID
+ var summary: String
+ var currentState: OperationState
+ var evidence: [RecoveryEvidence]
+ var suggestedOutcome: RecoveryOutcome
+ var explanation: String
+}
+
+/// 只读分析真实文件当前状态。它不修复、不移动文件,只把证据和建议交给用户确认。
+actor RecoveryAnalyzer {
+ private let fileManager: FileManager
+
+ init(fileManager: FileManager = .default) {
+ self.fileManager = fileManager
+ }
+
+ func analyze(records: [OperationRecord]) -> [RecoveryCase] {
+ records
+ .filter { [.pending, .undoing, .redoing, .unavailable].contains($0.state) }
+ .map(analyze)
+ }
+
+ private func analyze(_ record: OperationRecord) -> RecoveryCase {
+ var evidence: [RecoveryEvidence] = record.actions.enumerated().map { index, action in
+ self.evidence(for: action, index: index)
+ }
+ if !record.displacements.isEmpty {
+ evidence.append(RecoveryEvidence(
+ id: "displacements",
+ itemName: "同名项目保护记录",
+ observation: "本操作包含 \(record.displacements.count) 个冲突保护项,必须人工核对。",
+ supportsApplied: false,
+ supportsUndone: false
+ ))
+ }
+
+ let outcome: RecoveryOutcome
+ let explanation: String
+ if evidence.isEmpty {
+ outcome = .manualReview
+ explanation = "日志中没有已提交的文件步骤,但无法排除中断发生在文件修改与动作落盘之间。请先在访达核对。"
+ } else if hasCompleteAppliedCoverage(record), evidence.allSatisfy({ $0.supportsApplied }) {
+ outcome = .applied
+ explanation = "所有已记录步骤的磁盘状态都与“操作已完成”一致。"
+ } else if evidence.allSatisfy({ $0.supportsUndone }) {
+ outcome = .undone
+ explanation = "所有已记录步骤的磁盘状态都与“操作已回滚”一致。"
+ } else {
+ outcome = .manualReview
+ explanation = "磁盘证据不一致或同时存在两种状态,App 不会自动猜测。请先在访达核对。"
+ }
+ return RecoveryCase(
+ recordID: record.id,
+ summary: record.summary,
+ currentState: record.state,
+ evidence: evidence,
+ suggestedOutcome: outcome,
+ explanation: explanation
+ )
+ }
+
+ private func evidence(for action: ReversibleFileAction, index: Int) -> RecoveryEvidence {
+ switch action {
+ case let .relocate(value):
+ let originalExists = exists(value.originalPath)
+ let destinationExists = exists(value.destinationPath)
+ return RecoveryEvidence(
+ id: "\(index)-relocate",
+ itemName: URL(fileURLWithPath: value.destinationPath).lastPathComponent,
+ observation: existenceText(original: originalExists, destination: destinationExists),
+ supportsApplied: destinationExists && !originalExists,
+ supportsUndone: originalExists && !destinationExists
+ )
+ case let .materialize(value):
+ let destinationExists = exists(value.destinationPath)
+ let trashExists = value.undoTrashPath.map(exists) ?? false
+ return RecoveryEvidence(
+ id: "\(index)-materialize",
+ itemName: URL(fileURLWithPath: value.destinationPath).lastPathComponent,
+ observation: destinationExists ? "目标项目存在" : (trashExists ? "目标不存在,撤销副本仍在废纸篓" : "目标项目不存在"),
+ supportsApplied: destinationExists,
+ // “目标不存在”也可能是用户在访达中另行删除。只有撤销记录中的
+ // 废纸篓路径真实存在时,才能证明 materialize 已被回滚。
+ supportsUndone: !destinationExists && value.undoTrashPath != nil && trashExists
+ )
+ case let .discard(value):
+ let originalExists = exists(value.originalPath)
+ let trashExists = value.trashPath.map(exists) ?? false
+ return RecoveryEvidence(
+ id: "\(index)-discard",
+ itemName: URL(fileURLWithPath: value.originalPath).lastPathComponent,
+ observation: originalExists ? "原位置项目存在" : (trashExists ? "原位置不存在,废纸篓项目存在" : "原位置和记录的废纸篓位置都不存在"),
+ supportsApplied: !originalExists && trashExists,
+ supportsUndone: originalExists && !trashExists
+ )
+ case let .tags(value):
+ let url = URL(fileURLWithPath: value.path)
+ let current = (try? url.resourceValues(forKeys: [.tagNamesKey]).tagNames) ?? []
+ let normalizedCurrent = normalizedTags(current)
+ return RecoveryEvidence(
+ id: "\(index)-tags",
+ itemName: url.lastPathComponent,
+ observation: "当前标签数:\(current.count)",
+ supportsApplied: normalizedCurrent == normalizedTags(value.after),
+ supportsUndone: normalizedCurrent == normalizedTags(value.before)
+ )
+ }
+ }
+
+ private func exists(_ path: String) -> Bool {
+ fileManager.fileExists(atPath: path)
+ }
+
+ private func existenceText(original: Bool, destination: Bool) -> String {
+ switch (original, destination) {
+ case (true, false): "仅原位置存在"
+ case (false, true): "仅目标位置存在"
+ case (true, true): "原位置和目标位置都存在"
+ case (false, false): "原位置和目标位置都不存在"
+ }
+ }
+
+ private func normalizedTags(_ tags: [String]) -> [String] {
+ tags.map { $0.components(separatedBy: "\n").first ?? $0 }.sorted()
+ }
+
+ /// 单个动作的磁盘证据清晰,不代表整个批量操作已完成。例如复制 3 个文件时
+ /// 崩溃在第 1 个之后,日志中会有 1 个 materialize,但不能因此宣布整批完成。
+ private func hasCompleteAppliedCoverage(_ record: OperationRecord) -> Bool {
+ switch record.kind {
+ case .layout:
+ return false
+ case .rename:
+ return record.actions.contains { action in
+ if case .relocate = action { return true }
+ return false
+ }
+ case .createFolder, .createDocument:
+ return record.actions.contains { action in
+ if case .materialize = action { return true }
+ return false
+ }
+ case .trash:
+ return actionCount(in: record, matching: .discard) >= max(1, record.itemNames.count)
+ case .tags:
+ return actionCount(in: record, matching: .tags) >= max(1, record.itemNames.count)
+ case .duplicate, .copyItems, .compress:
+ return actionCount(in: record, matching: .materialize) >= max(1, record.itemNames.count)
+ case .moveItems:
+ return actionCount(in: record, matching: .relocate) >= max(1, record.itemNames.count)
+ }
+ }
+
+ private enum ActionType {
+ case relocate
+ case materialize
+ case discard
+ case tags
+ }
+
+ private func actionCount(in record: OperationRecord, matching type: ActionType) -> Int {
+ record.actions.reduce(into: 0) { count, action in
+ switch (action, type) {
+ case (.relocate, .relocate), (.materialize, .materialize), (.discard, .discard), (.tags, .tags):
+ count += 1
+ default:
+ break
+ }
+ }
+ }
+}
diff --git a/Sources/SpatialFolder/SpatialFolderApp.swift b/Sources/SpatialFolder/SpatialFolderApp.swift
index b30c69b..1971dc4 100644
--- a/Sources/SpatialFolder/SpatialFolderApp.swift
+++ b/Sources/SpatialFolder/SpatialFolderApp.swift
@@ -2,7 +2,13 @@ import SwiftUI
@main
struct SpatialFolderApp: App {
- @StateObject private var model = FolderCanvasModel()
+ @StateObject private var model: FolderCanvasModel
+
+ init() {
+ // 2.4 起使用稳定 Bundle ID;构造模型前先迁移旧测试版的最近空间和外观设置。
+ PreferencesMigrator.migrateIfNeeded()
+ _model = StateObject(wrappedValue: FolderCanvasModel())
+ }
var body: some Scene {
WindowGroup("空间文件夹") {
@@ -14,10 +20,10 @@ struct SpatialFolderApp: App {
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .undoRedo) {
- Button("撤销布局修改") { model.undoLayoutChange() }
+ Button("撤销上一步操作") { model.undoLastAction() }
.keyboardShortcut("z", modifiers: .command)
.disabled(!model.canUndo)
- Button("重做布局修改") { model.redoLayoutChange() }
+ Button("重做上一步操作") { model.redoLastAction() }
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!model.canRedo)
}
@@ -39,6 +45,9 @@ struct SpatialFolderApp: App {
.disabled(model.folderURL == nil)
Button("导入布局…") { model.importLayout() }
.disabled(model.folderURL == nil)
+ Divider()
+ Button("导出诊断信息…") { model.exportDiagnostics() }
+ .disabled(model.folderURL == nil)
}
}
}
diff --git a/Sources/SpatialFolder/WallpaperImageLoader.swift b/Sources/SpatialFolder/WallpaperImageLoader.swift
new file mode 100644
index 0000000..d55e761
--- /dev/null
+++ b/Sources/SpatialFolder/WallpaperImageLoader.swift
@@ -0,0 +1,52 @@
+import AppKit
+import Foundation
+import ImageIO
+
+/// 壁纸加载请求。像素尺寸按档位取整,避免窗口每变化 1 像素就产生一份新缓存。
+struct WallpaperImageRequest: Hashable, Sendable {
+ let url: URL
+ let maximumPixelSize: Int
+
+ init(url: URL, requestedPixelSize: CGFloat) {
+ self.url = url.standardizedFileURL
+ let clamped = min(max(512, Int(requestedPixelSize.rounded(.up))), 8_192)
+ maximumPixelSize = ((clamped + 255) / 256) * 256
+ }
+
+ var cacheKey: String { "\(url.path)|\(maximumPixelSize)" }
+}
+
+/// 在后台解码并缩小壁纸,避免 SwiftUI 每次刷新都在主线程读取完整原图。
+actor WallpaperImageLoader {
+ static let shared = WallpaperImageLoader()
+
+ private var cache: [String: CGImage] = [:]
+ private var insertionOrder: [String] = []
+ private let maximumCacheCount = 8
+
+ func image(for request: WallpaperImageRequest) -> CGImage? {
+ if let cached = cache[request.cacheKey] { return cached }
+ guard let source = CGImageSourceCreateWithURL(request.url as CFURL, nil) else { return nil }
+ let options: [CFString: Any] = [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: request.maximumPixelSize,
+ kCGImageSourceShouldCacheImmediately: true
+ ]
+ guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
+ return nil
+ }
+ cache[request.cacheKey] = image
+ insertionOrder.append(request.cacheKey)
+ while insertionOrder.count > maximumCacheCount {
+ let removed = insertionOrder.removeFirst()
+ cache.removeValue(forKey: removed)
+ }
+ return image
+ }
+
+ func removeAll() {
+ cache.removeAll()
+ insertionOrder.removeAll()
+ }
+}
diff --git a/Sources/SpatialFolder/WindowAspectRatioController.swift b/Sources/SpatialFolder/WindowAspectRatioController.swift
new file mode 100644
index 0000000..108c1b9
--- /dev/null
+++ b/Sources/SpatialFolder/WindowAspectRatioController.swift
@@ -0,0 +1,342 @@
+import AppKit
+import SwiftUI
+
+enum WindowAspectSizing {
+ static func isMaximumFrameProposal(
+ proposedFrameSize: CGSize,
+ visibleFrameSize: CGSize,
+ tolerance: CGFloat = 2
+ ) -> Bool {
+ proposedFrameSize.width >= visibleFrameSize.width - tolerance &&
+ proposedFrameSize.height >= visibleFrameSize.height - tolerance
+ }
+
+ static func constrainedContentSize(
+ proposedSize: CGSize,
+ currentSize: CGSize,
+ canvasSize: CGSize,
+ fixedChromeHeight: CGFloat,
+ minimumSize: CGSize = .zero,
+ maximumSize: CGSize = CGSize(
+ width: CGFloat.greatestFiniteMagnitude,
+ height: CGFloat.greatestFiniteMagnitude
+ )
+ ) -> CGSize {
+ guard canvasSize.width > 0, canvasSize.height > 0 else { return proposedSize }
+
+ let chromeHeight = max(0, fixedChromeHeight)
+ let widthChange = abs(proposedSize.width - currentSize.width) / max(1, currentSize.width)
+ let heightChange = abs(proposedSize.height - currentSize.height) / max(1, currentSize.height)
+ let proposedScale: CGFloat
+ if heightChange > widthChange {
+ proposedScale = max(0, proposedSize.height - chromeHeight) / canvasSize.height
+ } else {
+ proposedScale = proposedSize.width / canvasSize.width
+ }
+
+ let minimumScale = max(
+ minimumSize.width / canvasSize.width,
+ max(0, minimumSize.height - chromeHeight) / canvasSize.height
+ )
+ let maximumScale = min(
+ maximumSize.width / canvasSize.width,
+ max(0, maximumSize.height - chromeHeight) / canvasSize.height
+ )
+
+ // 如果配置的最小窗口在小屏幕上放不下,以“能完整落在当前屏幕”为最高优先级。
+ let lowerBound = min(minimumScale, maximumScale)
+ let scale = min(max(proposedScale, lowerBound), maximumScale)
+ return CGSize(
+ width: canvasSize.width * scale,
+ height: canvasSize.height * scale + chromeHeight
+ )
+ }
+}
+
+struct WindowAspectRatioController: NSViewRepresentable {
+ var canvasSize: CGSize
+ var fixedChromeHeight: CGFloat
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(canvasSize: canvasSize, fixedChromeHeight: fixedChromeHeight)
+ }
+
+ func makeNSView(context: Context) -> WindowProbeView {
+ let view = WindowProbeView()
+ view.windowDidChange = { [weak coordinator = context.coordinator] window in
+ coordinator?.attach(to: window)
+ }
+ return view
+ }
+
+ func updateNSView(_ nsView: WindowProbeView, context: Context) {
+ context.coordinator.update(canvasSize: canvasSize, fixedChromeHeight: fixedChromeHeight)
+ if let window = nsView.window {
+ context.coordinator.attach(to: window)
+ }
+ }
+
+ static func dismantleNSView(_ nsView: WindowProbeView, coordinator: Coordinator) {
+ nsView.windowDidChange = nil
+ coordinator.detach()
+ }
+
+ @MainActor
+ final class Coordinator {
+ private weak var window: NSWindow?
+ private var proxy: WindowResizeDelegateProxy?
+ private var observers: [NSObjectProtocol] = []
+ private var canvasSize: CGSize
+ private var fixedChromeHeight: CGFloat
+ private var scheduledAdjustment = false
+
+ init(canvasSize: CGSize, fixedChromeHeight: CGFloat) {
+ self.canvasSize = canvasSize
+ self.fixedChromeHeight = fixedChromeHeight
+ }
+
+ func update(canvasSize: CGSize, fixedChromeHeight: CGFloat) {
+ let changed = abs(self.canvasSize.width - canvasSize.width) > 0.5 ||
+ abs(self.canvasSize.height - canvasSize.height) > 0.5 ||
+ abs(self.fixedChromeHeight - fixedChromeHeight) > 0.5
+ self.canvasSize = canvasSize
+ self.fixedChromeHeight = fixedChromeHeight
+ proxy?.configuration = configuration
+ if changed { scheduleAdjustment() }
+ }
+
+ func attach(to newWindow: NSWindow?) {
+ guard window !== newWindow else {
+ installProxyIfNeeded()
+ return
+ }
+ detach()
+ guard let newWindow else { return }
+ window = newWindow
+ installProxyIfNeeded()
+ observe(newWindow)
+ scheduleAdjustment()
+ }
+
+ func detach() {
+ for observer in observers {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ observers.removeAll()
+ if let window, let proxy, window.delegate === proxy {
+ window.delegate = proxy.originalDelegate
+ }
+ proxy = nil
+ window = nil
+ scheduledAdjustment = false
+ }
+
+ private var configuration: WindowAspectConfiguration {
+ WindowAspectConfiguration(canvasSize: canvasSize, fixedChromeHeight: fixedChromeHeight)
+ }
+
+ private func installProxyIfNeeded() {
+ guard let window else { return }
+ if let proxy, window.delegate === proxy {
+ proxy.configuration = configuration
+ return
+ }
+ let newProxy = WindowResizeDelegateProxy(
+ originalDelegate: window.delegate,
+ configuration: configuration
+ )
+ proxy = newProxy
+ window.delegate = newProxy
+ }
+
+ private func observe(_ window: NSWindow) {
+ let center = NotificationCenter.default
+ observers.append(center.addObserver(
+ forName: NSWindow.willEnterFullScreenNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.proxy?.bypassConstraints = true }
+ })
+ observers.append(center.addObserver(
+ forName: NSWindow.didEnterFullScreenNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.proxy?.bypassConstraints = false }
+ })
+ observers.append(center.addObserver(
+ forName: NSWindow.willExitFullScreenNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.proxy?.bypassConstraints = true }
+ })
+ observers.append(center.addObserver(
+ forName: NSWindow.didExitFullScreenNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ self?.proxy?.bypassConstraints = false
+ self?.scheduleAdjustment()
+ }
+ })
+ observers.append(center.addObserver(
+ forName: NSWindow.didChangeScreenNotification,
+ object: window,
+ queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.scheduleAdjustment() }
+ })
+ }
+
+ private func scheduleAdjustment() {
+ guard !scheduledAdjustment else { return }
+ scheduledAdjustment = true
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.scheduledAdjustment = false
+ self.adjustCurrentWindowIfNeeded()
+ }
+ }
+
+ private func adjustCurrentWindowIfNeeded() {
+ guard let window,
+ !window.styleMask.contains(.fullScreen),
+ !window.isZoomed,
+ !isUsingMaximumFrame(window),
+ proxy?.bypassConstraints != true else { return }
+ installProxyIfNeeded()
+
+ let currentContentSize = window.contentView?.bounds.size ??
+ window.contentRect(forFrameRect: window.frame).size
+ let targetContentSize = WindowAspectSizing.constrainedContentSize(
+ proposedSize: currentContentSize,
+ currentSize: currentContentSize,
+ canvasSize: canvasSize,
+ fixedChromeHeight: fixedChromeHeight,
+ minimumSize: window.contentMinSize,
+ maximumSize: maximumContentSize(for: window)
+ )
+ guard abs(targetContentSize.width - currentContentSize.width) > 0.5 ||
+ abs(targetContentSize.height - currentContentSize.height) > 0.5 else { return }
+
+ let frameSize = window.frameRect(
+ forContentRect: NSRect(origin: .zero, size: targetContentSize)
+ ).size
+ var targetFrame = window.frame
+ let originalTop = targetFrame.maxY
+ targetFrame.size = frameSize
+ targetFrame.origin.y = originalTop - frameSize.height
+ targetFrame = constrainedFrame(targetFrame, to: window.screen?.visibleFrame)
+ window.setFrame(targetFrame, display: true)
+ }
+
+ private func maximumContentSize(for window: NSWindow) -> CGSize {
+ guard let visibleSize = window.screen?.visibleFrame.size else {
+ return CGSize(
+ width: CGFloat.greatestFiniteMagnitude,
+ height: CGFloat.greatestFiniteMagnitude
+ )
+ }
+ return window.contentRect(
+ forFrameRect: NSRect(origin: .zero, size: visibleSize)
+ ).size
+ }
+
+ private func isUsingMaximumFrame(_ window: NSWindow) -> Bool {
+ guard let visibleFrameSize = window.screen?.visibleFrame.size else { return false }
+ return WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: window.frame.size,
+ visibleFrameSize: visibleFrameSize
+ )
+ }
+
+ private func constrainedFrame(_ frame: NSRect, to visibleFrame: NSRect?) -> NSRect {
+ guard let visibleFrame else { return frame }
+ var result = frame
+ if result.maxX > visibleFrame.maxX { result.origin.x -= result.maxX - visibleFrame.maxX }
+ if result.minX < visibleFrame.minX { result.origin.x = visibleFrame.minX }
+ if result.maxY > visibleFrame.maxY { result.origin.y -= result.maxY - visibleFrame.maxY }
+ if result.minY < visibleFrame.minY { result.origin.y = visibleFrame.minY }
+ return result
+ }
+ }
+}
+
+final class WindowProbeView: NSView {
+ var windowDidChange: ((NSWindow?) -> Void)?
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ windowDidChange?(window)
+ }
+}
+
+private struct WindowAspectConfiguration {
+ var canvasSize: CGSize
+ var fixedChromeHeight: CGFloat
+}
+
+private final class WindowResizeDelegateProxy: NSObject, NSWindowDelegate {
+ weak var originalDelegate: NSWindowDelegate?
+ var configuration: WindowAspectConfiguration
+ var bypassConstraints = false
+
+ init(originalDelegate: NSWindowDelegate?, configuration: WindowAspectConfiguration) {
+ self.originalDelegate = originalDelegate
+ self.configuration = configuration
+ }
+
+ func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
+ let proposedFrameSize = originalDelegate?.windowWillResize?(sender, to: frameSize) ?? frameSize
+ guard !bypassConstraints, !sender.styleMask.contains(.fullScreen) else {
+ return proposedFrameSize
+ }
+ if let visibleFrameSize = sender.screen?.visibleFrame.size,
+ WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: proposedFrameSize,
+ visibleFrameSize: visibleFrameSize
+ ) {
+ return proposedFrameSize
+ }
+
+ let proposedContentSize = sender.contentRect(
+ forFrameRect: NSRect(origin: .zero, size: proposedFrameSize)
+ ).size
+ let currentContentSize = sender.contentView?.bounds.size ??
+ sender.contentRect(forFrameRect: sender.frame).size
+ let maximumContentSize: CGSize
+ if let visibleSize = sender.screen?.visibleFrame.size {
+ maximumContentSize = sender.contentRect(
+ forFrameRect: NSRect(origin: .zero, size: visibleSize)
+ ).size
+ } else {
+ maximumContentSize = CGSize(
+ width: CGFloat.greatestFiniteMagnitude,
+ height: CGFloat.greatestFiniteMagnitude
+ )
+ }
+ let contentSize = WindowAspectSizing.constrainedContentSize(
+ proposedSize: proposedContentSize,
+ currentSize: currentContentSize,
+ canvasSize: configuration.canvasSize,
+ fixedChromeHeight: configuration.fixedChromeHeight,
+ minimumSize: sender.contentMinSize,
+ maximumSize: maximumContentSize
+ )
+ return sender.frameRect(
+ forContentRect: NSRect(origin: .zero, size: contentSize)
+ ).size
+ }
+
+ override func responds(to aSelector: Selector!) -> Bool {
+ super.responds(to: aSelector) || (originalDelegate?.responds(to: aSelector) ?? false)
+ }
+
+ override func forwardingTarget(for aSelector: Selector!) -> Any? {
+ if originalDelegate?.responds(to: aSelector) == true { return originalDelegate }
+ return super.forwardingTarget(for: aSelector)
+ }
+}
diff --git a/TECHNICAL_2.2.md b/TECHNICAL_2.2.md
new file mode 100644
index 0000000..6b86acf
--- /dev/null
+++ b/TECHNICAL_2.2.md
@@ -0,0 +1,39 @@
+# 空间文件夹 2.2:文件安全与撤销技术说明
+
+## 设计目标
+
+2.2 不把画布变成独立文件系统。真实文件仍是唯一真相源;新增层只记录“哪些真实变更已经发生,如何安全地反向执行”。
+
+## 数据结构
+
+- `OperationRecord`:操作类型、状态、时间、用户可读摘要、可逆文件动作和画布元数据。
+- `ReversibleFileAction`:路径迁移、新生项目、移除项目和 Finder 标签变更。
+- `OperationHistoryStore`:每个根文件夹一份 JSON 日志,使用原子写入,最多保留 200 条。
+- `FileOperationEngine`:在 `applied` 和 `undone` 之间执行可逆迁移,并处理同名冲突。
+
+## 状态规则
+
+1. 真实文件修改前,先持久化 `pending` 记录。
+2. 文件系统操作完成后更新为 `applied`。
+3. 撤销后为 `undone`,重做后回到 `applied`。
+4. 新操作发生时,旧的重做分支标记为 `superseded`。
+5. 重启后遇到 `pending` 记录,标记为 `unavailable`并要求用户在 Finder 核对。
+6. 布局快照不跨重启;对应历史会标记为 `viewOnly`。
+
+## 覆盖为何可逆
+
+“替换”不使用直接覆写。旧项目先进入废纸篓,新项目再进入目标路径。撤销时按相反顺序执行:先移开新项目,再恢复旧项目。恢复过程如再次遇到冲突,仍必须由用户选择。
+
+## 数据安全防线
+
+- 操作前预检所有源路径,避免多项撤销做到一半才发现缺少文件。
+- 同名项目默认不覆盖。
+- 损坏日志不会被静默覆写;真实文件修改会暂停,原日志可存档到 `Operations/Corrupt`。
+- 根文件夹移动后,画布内部路径会按新根目录迁移,外部导入源路径保持不变。
+- 诊断报告只包含版本、屏幕/画布尺寸、项目数量、操作类型/状态和错误类别。
+
+## 仍然存在的边界
+
+- 外部 App 在操作后再次移动或删除目标时,无法无损推断其新位置;引擎会停止而不是猜测。
+- 系统崩溃可能发生在文件系统调用与日志状态更新之间的极短窗口;重启后会将该操作标记为需核对,不会自动继续。
+- 当前未处理多设备同时编辑 iCloud 画布日志的合并冲突。
diff --git a/Tests/Performance/PerformanceMain.swift b/Tests/Performance/PerformanceMain.swift
new file mode 100644
index 0000000..5b27053
--- /dev/null
+++ b/Tests/Performance/PerformanceMain.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+@main
+struct SpatialFolderPerformanceBaseline {
+ static func main() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("SpatialFolderPerformance-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+
+ var scans: [String: Double] = [:]
+ for count in [64, 500, 3_000] {
+ let folder = root.appendingPathComponent("scan-\(count)", isDirectory: true)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ for index in 0.. Double {
+ let components = duration.components
+ return Double(components.seconds) + Double(components.attoseconds) / 1_000_000_000_000_000_000
+ }
+}
diff --git a/Tests/SelfTests/SelfTestMain.swift b/Tests/SelfTests/SelfTestMain.swift
index 2010913..8201106 100644
--- a/Tests/SelfTests/SelfTestMain.swift
+++ b/Tests/SelfTests/SelfTestMain.swift
@@ -16,19 +16,64 @@ struct SpatialFolderSelfTests {
private static var passed = 0
private static var failed = 0
- static func main() {
+ static func main() async {
run("备份数量限制与恢复") { try testBackupRetentionAndRestore() }
run("损坏布局自动恢复") { try testCorruptRecovery() }
run("无备份损坏布局阻断") { try testCorruptWithoutBackupBlocks() }
run("旧版路径布局迁移") { try testLegacyMigration() }
run("跨文件夹布局导入拒绝") { try testWrongFolderImport() }
+ run("操作记录持久化与数量限制") { try testOperationHistoryPersistence() }
+ run("2.4 操作记录迁移到增量日志") { try testOperationJournalLegacyMigration() }
+ run("增量日志重放与自动压缩") { try testOperationJournalReplayAndCompaction() }
+ run("断电日志半行可安全忽略并继续追加") { try testOperationJournalRepairsPartialTail() }
+ await runAsync("1000 次增量日志追加保持线性性能") { try await testOperationJournalPerformance() }
+ run("重命名事务撤销与重做") { try testRelocateUndoRedo() }
+ run("新建事务撤销后保留内容") { try testMaterializeUndoRedo() }
+ run("废纸篓事务撤销与重做") { try testDiscardUndoRedo() }
+ run("Finder 标签事务撤销与重做") { try testTagUndoRedo() }
+ run("恢复冲突保留两者") { try testConflictKeepBoth() }
+ run("恢复冲突替换后可逆") { try testConflictReplaceRoundTrip() }
+ run("多项撤销预检防止部分执行") { try testMultiActionPreflightPreventsPartialUndo() }
+ run("损坏操作记录阻止静默覆盖") { try testCorruptOperationHistoryBlocks() }
+ run("画布跨进程锁独占与释放") { try testCanvasSessionLockExclusivity() }
+ run("第二个画布模型进入只读模式") { try testSecondModelUsesReadOnlySession() }
+ run("2.3.2 偏好迁移到稳定版本") { try testPreferencesMigration() }
+ run("3000 项目录扫描保持可接受耗时") { try testLargeFolderScanPerformance() }
+ await runAsync("后台批量复制逐步记录并可撤销") { try await testCoordinatedTransferRoundTrip() }
+ await runAsync("后台批量失败自动回滚") { try await testCoordinatedTransferFailureRollsBack() }
+ await runAsync("模型生产路径异步导入不阻塞并落账") { try await testModelAsynchronousImport() }
+ await runAsync("短文件操作生产路径全部后台执行") { try await testBackgroundShortFileOperations() }
+ await runAsync("异常恢复分析只依据磁盘证据") { try await testRecoveryAnalyzerEvidence() }
+ run("App 新建文件夹统一撤销重做") { try testModelCreateFolderUndoRedo() }
+ run("App 重命名恢复路径与画布位置") { try testModelRenameUndoRedo() }
+ run("App 制作副本统一撤销重做") { try testModelDuplicateUndoRedo() }
+ run("App 移至废纸篓恢复真实文件与位置") { try testModelTrashUndoRestoresLayout() }
+ run("导入替换操作可完整撤销重做") { try testImportReplaceUndoRedo() }
+ run("文件撤销跨重启保留") { try testFileUndoSurvivesRestart() }
+ run("根文件夹移动后文件撤销路径自动迁移") { try testFileUndoAfterRootFolderMove() }
+ run("布局与文件按时序统一撤销") { try testUnifiedUndoOrdering() }
+ run("超出布局撤销深度后历史状态如实标记") { try testLayoutUndoDepthMarksViewOnly() }
+ run("重命名冲突必须明确选择") { try testRenameConflictRequiresChoice() }
+ run("诊断导出不含文件名和路径") { try testDiagnosticsPrivacy() }
+ run("未完成操作在重启后标记核对") { try testPendingOperationMarkedUnavailableOnRestart() }
+ run("损坏操作记录阻止 App 真实文件修改") { try testCorruptHistoryBlocksModelMutations() }
run("70 项目进入 64+6 布局") { try testSeventyItemOverflow() }
run("待放置区与主画布交换") { try testInboxSwap() }
+ run("待放置区批量放回") { try testBatchInboxPlacement() }
+ run("外部拖入按落点排列且不移动已有项目") { try testDropLocationPlacement() }
+ run("多选整体拖动保持相对位置") { try testGroupDragPreservesRelativeLayout() }
+ run("多选批量副本和废纸篓") { try testBatchDuplicateAndTrash() }
run("外部重命名保持位置") { try testExternalRenameKeepsPosition() }
run("撤销、重做和锁定") { try testUndoRedoAndLock() }
run("锁定状态按文件夹持久保存") { try testLockPersistsPerFolder() }
run("跨屏往返不改写布局") { try testScreenSwitchDoesNotMutateLayout() }
run("小屏视口统一缩放") { try testViewportScaleIsUniform() }
+ run("全屏额外高度由画布背景覆盖") { try testPresentationCoversTallerViewport() }
+ run("小视口不会缩短逻辑画布") { try testPresentationNeverShrinksLogicalCanvas() }
+ run("普通窗口按宽度保持画布比例") { try testWindowAspectTracksWidth() }
+ run("普通窗口按高度保持画布比例") { try testWindowAspectTracksHeight() }
+ run("窗口比例受当前显示器范围限制") { try testWindowAspectFitsCurrentDisplay() }
+ run("最大化窗口使用完整可见区域") { try testMaximumWindowBypassesAspectConstraint() }
run("基准画布重设与撤销") { try testReferenceCanvasResetAndUndo() }
run("小屏重启保持原基准画布") { try testRestartOnSmallerDisplayKeepsReferenceCanvas() }
run("v4 压缩布局迁移到大屏基准") { try testVersionFourReferenceMigration() }
@@ -51,6 +96,17 @@ struct SpatialFolderSelfTests {
}
}
+ private static func runAsync(_ name: String, _ body: () async throws -> Void) async {
+ do {
+ try await body()
+ passed += 1
+ print("✓ \(name)")
+ } catch {
+ failed += 1
+ print("✗ \(name):\(error)")
+ }
+ }
+
private static func check(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw SelfTestFailure(description: message) }
}
@@ -139,6 +195,1002 @@ struct SpatialFolderSelfTests {
}
}
+ private static func testOperationHistoryPersistence() throws {
+ let directory = temporaryDirectory(prefix: "Operations")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let store = OperationHistoryStore(directory: directory, maximumRecords: 20)
+ let key = "canvas"
+ var document = OperationHistoryDocument()
+ for index in 0..<24 {
+ document.records.append(OperationRecord(
+ category: .file,
+ kind: .createDocument,
+ summary: "记录 \(index)",
+ state: .applied,
+ actions: [.materialize(MaterializeAction(destinationPath: "/tmp/\(index)"))]
+ ))
+ }
+ try store.save(document, canvasKey: key)
+ let loaded = try store.load(canvasKey: key)
+ try check(loaded.records.count == 20, "操作记录没有限制为 20 条")
+ try check(loaded.records.first?.summary == "记录 4", "操作记录没有保留最新项目")
+ try check(loaded.records.last?.state == .applied, "操作状态没有持久化")
+ }
+
+ private static func testOperationJournalLegacyMigration() throws {
+ let directory = temporaryDirectory(prefix: "JournalMigration")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let legacy = OperationHistoryStore(directory: directory, maximumRecords: 20)
+ let key = "canvas"
+ let record = OperationRecord(
+ category: .file,
+ kind: .createFolder,
+ summary: "2.4 记录",
+ state: .applied,
+ actions: [.materialize(MaterializeAction(destinationPath: "/tmp/legacy"))]
+ )
+ try legacy.save(OperationHistoryDocument(records: [record]), canvasKey: key)
+
+ let journal = OperationJournalDiskStore(
+ directory: directory,
+ maximumRecords: 20,
+ compactionEventThreshold: 50
+ )
+ let loaded = try journal.load(canvasKey: key)
+ try check(loaded.records.count == 1, "迁移后记录数量发生变化")
+ try check(loaded.records.first?.id == record.id, "迁移后记录标识发生变化")
+ try check(loaded.records.first?.summary == record.summary, "迁移后记录摘要发生变化")
+ try check(loaded.records.first?.actions == record.actions, "迁移后可恢复动作发生变化")
+ try check(FileManager.default.fileExists(atPath: journal.snapshotURL(canvasKey: key).path), "迁移没有生成快照")
+ try check(FileManager.default.fileExists(atPath: journal.archivedLegacyURL(canvasKey: key).path), "2.4 原记录没有归档保留")
+ try check(!FileManager.default.fileExists(atPath: journal.legacyURL(canvasKey: key).path), "迁移后仍重复读取旧记录")
+ }
+
+ private static func testOperationJournalReplayAndCompaction() throws {
+ let directory = temporaryDirectory(prefix: "JournalReplay")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let journal = OperationJournalDiskStore(
+ directory: directory,
+ maximumRecords: 20,
+ compactionEventThreshold: 3,
+ compactionByteThreshold: 1_000_000
+ )
+ let key = "canvas"
+ var record = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "批量复制",
+ state: .pending
+ )
+ let first = try journal.append(.upsert([record]), canvasKey: key)
+ try check(first.appendedEventCount == 1 && !first.didCompact, "第一条事件错误触发压缩")
+ record.actions.append(.materialize(MaterializeAction(destinationPath: "/tmp/a")))
+ _ = try journal.append(.upsert([record]), canvasKey: key)
+ record.state = .applied
+ let compacted = try journal.append(.upsert([record]), canvasKey: key)
+ try check(compacted.didCompact, "达到阈值后没有压缩日志")
+
+ let loaded = try journal.load(canvasKey: key)
+ try check(loaded.records.count == 1, "upsert 重放产生了重复记录")
+ try check(loaded.records.first?.id == record.id, "重放没有保持记录标识")
+ try check(loaded.records.first?.state == .applied, "重放没有恢复最终状态")
+ try check(loaded.records.first?.actions == record.actions, "重放没有恢复逐步动作")
+ let journalBytes = (try? Data(contentsOf: journal.journalURL(canvasKey: key)).count) ?? -1
+ try check(journalBytes == 0, "生成快照后没有截断增量日志")
+ }
+
+ private static func testOperationJournalPerformance() async throws {
+ let directory = temporaryDirectory(prefix: "JournalPerformance")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let diskStore = OperationJournalDiskStore(
+ directory: directory,
+ maximumRecords: 200,
+ compactionEventThreshold: 500,
+ compactionByteThreshold: 10_000_000
+ )
+ let journal = OperationJournalStore(diskStore: diskStore)
+ let key = "canvas"
+ var record = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "性能基线",
+ state: .pending
+ )
+ let started = ContinuousClock.now
+ for index in 0..<1_000 {
+ record.detail = "步骤 \(index)"
+ _ = try await journal.upsert(record, canvasKey: key)
+ }
+ let elapsed = started.duration(to: .now)
+ let loaded = try await journal.load(canvasKey: key)
+ try check(loaded.records.count == 1, "1000 次 upsert 重放后产生重复记录")
+ try check(loaded.records.first?.detail == "步骤 999", "1000 次追加没有恢复最终事件")
+ try check(elapsed < .seconds(10), "1000 次增量日志追加超过 10 秒预算:\(elapsed)")
+ }
+
+ private static func testOperationJournalRepairsPartialTail() throws {
+ let directory = temporaryDirectory(prefix: "JournalPartialTail")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let journal = OperationJournalDiskStore(directory: directory, compactionEventThreshold: 100)
+ let key = "partial-tail"
+ let first = OperationRecord(
+ category: .file,
+ kind: .createDocument,
+ summary: "已提交事件",
+ state: .pending
+ )
+ _ = try journal.append(.upsert([first]), canvasKey: key)
+ let journalURL = journal.journalURL(canvasKey: key)
+ let handle = try FileHandle(forWritingTo: journalURL)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: Data("{\"version\":1,\"mutation\":".utf8))
+ try handle.close()
+
+ let loadedBeforeRepair = try journal.load(canvasKey: key)
+ try check(loadedBeforeRepair.records.first?.id == first.id, "未完整尾行破坏了已提交记录")
+
+ var completed = first
+ completed.state = .applied
+ _ = try journal.append(.upsert([completed]), canvasKey: key)
+ let loadedAfterRepair = try journal.load(canvasKey: key)
+ try check(loadedAfterRepair.records.first?.state == .applied, "修剪半行后无法继续追加")
+
+ let corruptKey = "complete-corrupt-line"
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try Data("not-json\n".utf8).write(to: journal.journalURL(canvasKey: corruptKey))
+ do {
+ _ = try journal.load(canvasKey: corruptKey)
+ throw SelfTestFailure(description: "已换行的损坏事件被错误忽略")
+ } catch is OperationHistoryStoreError {
+ // 完整行损坏必须严格阻断,只允许忽略最后未换行半行。
+ }
+ }
+
+ private static func testRelocateUndoRedo() throws {
+ let base = temporaryDirectory(prefix: "Relocate")
+ defer { try? FileManager.default.removeItem(at: base) }
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ let original = base.appendingPathComponent("original.txt")
+ let destination = base.appendingPathComponent("renamed.txt")
+ try Data("rename".utf8).write(to: original)
+ try FileManager.default.moveItem(at: original, to: destination)
+ let engine = FileOperationEngine(trashDirectoryForTesting: base.appendingPathComponent("Trash"))
+ let record = OperationRecord(
+ category: .file,
+ kind: .rename,
+ summary: "重命名",
+ state: .applied,
+ actions: [.relocate(RelocateAction(originalPath: original.path, destinationPath: destination.path))]
+ )
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ try check(FileManager.default.fileExists(atPath: original.path), "撤销重命名没有恢复原路径")
+ let redone = try engine.transition(undone, to: .applied, conflictChoice: .cancel)
+ try check(FileManager.default.fileExists(atPath: destination.path), "重做重命名没有恢复目标路径")
+ try check(redone.state == .applied, "重做后的状态不正确")
+ }
+
+ private static func testMaterializeUndoRedo() throws {
+ let base = temporaryDirectory(prefix: "Materialize")
+ defer { try? FileManager.default.removeItem(at: base) }
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ let destination = base.appendingPathComponent("created.txt")
+ try Data("edited-after-create".utf8).write(to: destination)
+ let engine = FileOperationEngine(trashDirectoryForTesting: base.appendingPathComponent("Trash"))
+ let record = OperationRecord(
+ category: .file,
+ kind: .createDocument,
+ summary: "新建文档",
+ state: .applied,
+ actions: [.materialize(MaterializeAction(destinationPath: destination.path))]
+ )
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ try check(!FileManager.default.fileExists(atPath: destination.path), "撤销新建后文件仍存在")
+ _ = try engine.transition(undone, to: .applied, conflictChoice: .cancel)
+ let restored = try String(contentsOf: destination, encoding: .utf8)
+ try check(restored == "edited-after-create", "重做没有保留撤销前的文件内容")
+ }
+
+ private static func testDiscardUndoRedo() throws {
+ let base = temporaryDirectory(prefix: "Discard")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let trash = base.appendingPathComponent("Trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
+ let original = base.appendingPathComponent("deleted.txt")
+ let trashURL = trash.appendingPathComponent("deleted.txt")
+ try Data("deleted".utf8).write(to: original)
+ try FileManager.default.moveItem(at: original, to: trashURL)
+ let engine = FileOperationEngine(trashDirectoryForTesting: trash)
+ let record = OperationRecord(
+ category: .file,
+ kind: .trash,
+ summary: "移至废纸篓",
+ state: .applied,
+ actions: [.discard(DiscardAction(originalPath: original.path, trashPath: trashURL.path))]
+ )
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ try check(FileManager.default.fileExists(atPath: original.path), "撤销删除没有恢复文件")
+ let redone = try engine.transition(undone, to: .applied, conflictChoice: .cancel)
+ guard case let .discard(action) = redone.actions.first else {
+ throw SelfTestFailure(description: "删除操作类型被改变")
+ }
+ try check(!FileManager.default.fileExists(atPath: original.path), "重做删除后原文件仍存在")
+ try check(action.trashPath.map { FileManager.default.fileExists(atPath: $0) } == true, "重做删除没有记录新废纸篓位置")
+ }
+
+ private static func testTagUndoRedo() throws {
+ let base = temporaryDirectory(prefix: "Tags")
+ defer { try? FileManager.default.removeItem(at: base) }
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ let file = base.appendingPathComponent("tagged.txt")
+ try Data("tag".utf8).write(to: file)
+ let after = ["红色\n6"]
+ try (file as NSURL).setResourceValue(after, forKey: URLResourceKey.tagNamesKey)
+ let initiallySetTags = try file.resourceValues(forKeys: [.tagNamesKey]).tagNames ?? []
+ try check(initiallySetTags == ["红色"], "测试文件系统无法设置 Finder 标签:\(initiallySetTags)")
+ let record = OperationRecord(
+ category: .file,
+ kind: .tags,
+ summary: "修改标签",
+ state: .applied,
+ actions: [.tags(TagAction(path: file.path, before: [], after: after))]
+ )
+ let engine = FileOperationEngine(trashDirectoryForTesting: base.appendingPathComponent("Trash"))
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ let undoneTags = try URL(fileURLWithPath: file.path)
+ .resourceValues(forKeys: [.tagNamesKey]).tagNames ?? []
+ try check(undoneTags.isEmpty, "撤销没有清除 Finder 标签:\(undoneTags)")
+ _ = try engine.transition(undone, to: .applied, conflictChoice: .cancel)
+ let redoneTags = try URL(fileURLWithPath: file.path)
+ .resourceValues(forKeys: [.tagNamesKey]).tagNames ?? []
+ try check(redoneTags == ["红色"], "重做没有恢复 Finder 标签:\(redoneTags)")
+ }
+
+ private static func testConflictKeepBoth() throws {
+ let base = temporaryDirectory(prefix: "KeepBoth")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let trash = base.appendingPathComponent("Trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
+ let original = base.appendingPathComponent("report.txt")
+ let trashURL = trash.appendingPathComponent("report.txt")
+ try Data("old".utf8).write(to: trashURL)
+ try Data("current".utf8).write(to: original)
+ let engine = FileOperationEngine(trashDirectoryForTesting: trash)
+ let record = OperationRecord(
+ category: .file,
+ kind: .trash,
+ summary: "恢复报告",
+ state: .applied,
+ actions: [.discard(DiscardAction(originalPath: original.path, trashPath: trashURL.path))]
+ )
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .keepBoth)
+ guard case let .discard(action) = undone.actions.first else {
+ throw SelfTestFailure(description: "恢复操作类型错误")
+ }
+ try check(action.originalPath != original.path, "保留两者没有生成新路径")
+ try check(FileManager.default.fileExists(atPath: action.originalPath), "旧文件没有恢复到新路径")
+ try check(FileManager.default.fileExists(atPath: original.path), "现有文件被覆盖")
+ }
+
+ private static func testConflictReplaceRoundTrip() throws {
+ let base = temporaryDirectory(prefix: "Replace")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let trash = base.appendingPathComponent("Trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
+ let original = base.appendingPathComponent("report.txt")
+ let oldTrash = trash.appendingPathComponent("old-report.txt")
+ try Data("old".utf8).write(to: oldTrash)
+ try Data("current".utf8).write(to: original)
+ let engine = FileOperationEngine(trashDirectoryForTesting: trash)
+ let record = OperationRecord(
+ category: .file,
+ kind: .trash,
+ summary: "恢复并替换",
+ state: .applied,
+ actions: [.discard(DiscardAction(originalPath: original.path, trashPath: oldTrash.path))]
+ )
+ let undone = try engine.transition(record, to: .undone, conflictChoice: .replace)
+ let restoredOldContent = try String(contentsOf: original, encoding: .utf8)
+ try check(restoredOldContent == "old", "替换后没有恢复旧文件")
+ try check(undone.displacements.count == 1, "被替换文件没有登记可恢复位置")
+ let redone = try engine.transition(undone, to: .applied, conflictChoice: .replace)
+ let restoredCurrentContent = try String(contentsOf: original, encoding: .utf8)
+ try check(restoredCurrentContent == "current", "重做后没有恢复被替换文件")
+ try check(redone.displacements.isEmpty, "重做后冲突位移记录没有清理")
+ }
+
+ private static func testMultiActionPreflightPreventsPartialUndo() throws {
+ let base = temporaryDirectory(prefix: "Preflight")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let existing = base.appendingPathComponent("existing.txt")
+ let missing = base.appendingPathComponent("missing.txt")
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ try Data("keep".utf8).write(to: existing)
+ let record = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "多项复制",
+ state: .applied,
+ actions: [
+ .materialize(MaterializeAction(destinationPath: missing.path)),
+ .materialize(MaterializeAction(destinationPath: existing.path))
+ ]
+ )
+ let engine = FileOperationEngine(trashDirectoryForTesting: base.appendingPathComponent("Trash"))
+ do {
+ _ = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ throw SelfTestFailure(description: "错误接受了缺少源文件的多项撤销")
+ } catch FileOperationTransitionError.missingSource {
+ try check(FileManager.default.fileExists(atPath: existing.path), "预检前就移走了其他文件")
+ }
+ }
+
+ private static func testCorruptOperationHistoryBlocks() throws {
+ let directory = temporaryDirectory(prefix: "Operations")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let store = OperationHistoryStore(directory: directory)
+ let url = store.historyURL(canvasKey: "broken")
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try Data("broken".utf8).write(to: url)
+ do {
+ _ = try store.load(canvasKey: "broken")
+ throw SelfTestFailure(description: "损坏操作记录被静默接受")
+ } catch OperationHistoryStoreError.corruptHistory {
+ let preserved = try Data(contentsOf: url)
+ try check(preserved == Data("broken".utf8), "损坏操作记录被覆盖")
+ let archive = try store.archiveCorruptHistoryAndReset(canvasKey: "broken")
+ let archivedData = try Data(contentsOf: archive)
+ try check(archivedData == Data("broken".utf8), "存档没有保留损坏原文件")
+ let reset = try store.load(canvasKey: "broken")
+ try check(reset.records.isEmpty, "存档后没有创建新操作记录")
+ }
+ }
+
+ private static func testCanvasSessionLockExclusivity() throws {
+ let directory = temporaryDirectory(prefix: "SessionLock")
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ let firstResult = try CanvasSessionLock.acquire(
+ canvasKey: "canvas-a",
+ directory: directory,
+ processID: 111,
+ appVersion: "2.4-test"
+ )
+ guard case let .acquired(firstLock) = firstResult else {
+ throw SelfTestFailure(description: "第一次没有取得画布锁")
+ }
+
+ let secondResult = try CanvasSessionLock.acquire(
+ canvasKey: "canvas-a",
+ directory: directory,
+ processID: 222,
+ appVersion: "2.4-test"
+ )
+ guard case let .occupied(owner) = secondResult else {
+ throw SelfTestFailure(description: "第二个会话错误取得了同一张画布的写入锁")
+ }
+ try check(owner?.processID == 111, "锁占用者信息没有正确保存")
+
+ firstLock.release()
+ let thirdResult = try CanvasSessionLock.acquire(
+ canvasKey: "canvas-a",
+ directory: directory,
+ processID: 333,
+ appVersion: "2.4-test"
+ )
+ guard case .acquired = thirdResult else {
+ throw SelfTestFailure(description: "原会话释放后仍无法取得画布锁")
+ }
+ }
+
+ private static func testSecondModelUsesReadOnlySession() throws {
+ let base = temporaryDirectory(prefix: "ModelSessionLock")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let folder = base.appendingPathComponent("Root", isDirectory: true)
+ let locks = base.appendingPathComponent("Locks", isDirectory: true)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+
+ let layoutStore = CanvasLayoutStore(layoutsDirectory: base.appendingPathComponent("Layouts"))
+ let operationStore = OperationHistoryStore(directory: base.appendingPathComponent("Operations"))
+ let fileEngine = FileOperationEngine(trashDirectoryForTesting: base.appendingPathComponent("Trash"))
+ let writerDefaults = try require(UserDefaults(suiteName: "SessionWriter.\(UUID().uuidString)"), "无法创建写会话偏好")
+ let readerDefaults = try require(UserDefaults(suiteName: "SessionReader.\(UUID().uuidString)"), "无法创建读会话偏好")
+
+ var writer: FolderCanvasModel? = FolderCanvasModel(
+ layoutStore: layoutStore,
+ operationStore: operationStore,
+ fileOperationEngine: fileEngine,
+ userDefaults: writerDefaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockDirectory: locks
+ )
+ writer?.open(folder: folder)
+ try check(writer?.sessionIsReadOnly == false, "第一个模型错误进入只读模式")
+
+ let reader = FolderCanvasModel(
+ layoutStore: layoutStore,
+ operationStore: operationStore,
+ fileOperationEngine: fileEngine,
+ userDefaults: readerDefaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockDirectory: locks
+ )
+ reader.open(folder: folder)
+ try check(reader.sessionIsReadOnly, "第二个模型没有进入只读模式")
+ reader.createFolder()
+ try check(
+ !FileManager.default.fileExists(atPath: folder.appendingPathComponent("新建文件夹").path),
+ "只读会话仍然修改了真实文件"
+ )
+
+ writer = nil
+ let successor = FolderCanvasModel(
+ layoutStore: layoutStore,
+ operationStore: operationStore,
+ fileOperationEngine: fileEngine,
+ userDefaults: writerDefaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockDirectory: locks
+ )
+ successor.open(folder: folder)
+ try check(!successor.sessionIsReadOnly, "写会话结束后新模型仍被错误阻止")
+ }
+
+ private static func testPreferencesMigration() throws {
+ let destinationName = "PreferencesDestination.\(UUID().uuidString)"
+ let newestName = "Preferences232.\(UUID().uuidString)"
+ let olderName = "Preferences231.\(UUID().uuidString)"
+ let destination = try require(UserDefaults(suiteName: destinationName), "无法创建目标偏好")
+ let newest = try require(UserDefaults(suiteName: newestName), "无法创建新版偏好")
+ let older = try require(UserDefaults(suiteName: olderName), "无法创建旧版偏好")
+ defer {
+ destination.removePersistentDomain(forName: destinationName)
+ newest.removePersistentDomain(forName: newestName)
+ older.removePersistentDomain(forName: olderName)
+ }
+ destination.removePersistentDomain(forName: destinationName)
+ newest.removePersistentDomain(forName: newestName)
+ older.removePersistentDomain(forName: olderName)
+
+ destination.set("dark", forKey: "appearanceMode")
+ newest.set("/tmp/latest", forKey: "lastOpenedFolderPath")
+ newest.set(["/tmp/latest"], forKey: "recentFolderPaths")
+ older.set("/tmp/older", forKey: "lastOpenedFolderPath")
+
+ let report = PreferencesMigrator.migrateIfNeeded(
+ destination: destination,
+ sources: [(newestName, newest), (olderName, older)]
+ )
+ try check(destination.string(forKey: "appearanceMode") == "dark", "迁移覆盖了 2.4 已有设置")
+ try check(destination.string(forKey: "lastOpenedFolderPath") == "/tmp/latest", "没有优先采用 2.3.2 设置")
+ try check(destination.stringArray(forKey: "recentFolderPaths") == ["/tmp/latest"], "最近空间没有迁移")
+ try check(report.sourceDomains == [newestName], "迁移来源记录不正确")
+ try check(
+ destination.integer(forKey: PreferencesMigrator.migrationVersionKey) == PreferencesMigrator.currentMigrationVersion,
+ "迁移版本没有写入"
+ )
+
+ newest.set("/tmp/changed", forKey: "lastOpenedFolderPath")
+ _ = PreferencesMigrator.migrateIfNeeded(
+ destination: destination,
+ sources: [(newestName, newest)]
+ )
+ try check(destination.string(forKey: "lastOpenedFolderPath") == "/tmp/latest", "重复迁移改变了已完成结果")
+ }
+
+ private static func testLargeFolderScanPerformance() throws {
+ let base = temporaryDirectory(prefix: "LargeScan")
+ defer { try? FileManager.default.removeItem(at: base) }
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ let payload = Data("scan".utf8)
+ for index in 0..<3_000 {
+ try payload.write(to: base.appendingPathComponent(String(format: "item-%05d.txt", index)))
+ }
+ let hidden = base.appendingPathComponent(".hidden.txt")
+ try payload.write(to: hidden)
+
+ let start = ContinuousClock.now
+ let entries = try FolderDirectoryScanner().scan(folder: base)
+ let elapsed = start.duration(to: .now)
+ try check(entries.count == 3_000, "目录扫描数量不正确或错误包含隐藏文件")
+ try check(entries.first?.url.lastPathComponent == "item-00000.txt", "扫描结果没有稳定排序")
+ try check(elapsed < .seconds(10), "3000 项目录扫描耗时过长:\(elapsed)")
+ }
+
+ private static func testCoordinatedTransferRoundTrip() async throws {
+ let base = temporaryDirectory(prefix: "CoordinatedTransfer")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let sourceFolder = base.appendingPathComponent("Source", isDirectory: true)
+ let destinationFolder = base.appendingPathComponent("Destination", isDirectory: true)
+ let trash = base.appendingPathComponent("Trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: sourceFolder, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: destinationFolder, withIntermediateDirectories: true)
+ let source = sourceFolder.appendingPathComponent("report.txt")
+ let destination = destinationFolder.appendingPathComponent("report.txt")
+ try Data("new".utf8).write(to: source)
+ try Data("old".utf8).write(to: destination)
+
+ let engine = FileOperationEngine(trashDirectoryForTesting: trash)
+ let coordinator = FileOperationCoordinator(fileOperationEngine: engine)
+ let collector = OperationEventCollector()
+ let actions = try await coordinator.performTransfers([
+ FileTransferPlan(
+ source: source,
+ destination: destination,
+ move: false,
+ replacesExistingDestination: true
+ )
+ ]) { event in
+ await collector.append(event)
+ }
+ try check(actions.count == 2, "替换复制没有记录保护旧文件和创建新文件两个步骤")
+ let copiedContent = try String(contentsOf: destination, encoding: .utf8)
+ let appliedActionCount = await collector.appliedActionCount()
+ try check(copiedContent == "new", "后台复制结果不正确")
+ try check(appliedActionCount == 2, "协调器没有逐步上报完成动作")
+
+ let record = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "测试后台复制",
+ state: .applied,
+ actions: actions
+ )
+ _ = try engine.transition(record, to: .undone, conflictChoice: .cancel)
+ let restoredContent = try String(contentsOf: destination, encoding: .utf8)
+ try check(restoredContent == "old", "撤销后没有恢复被替换的旧文件")
+ }
+
+ private static func testCoordinatedTransferFailureRollsBack() async throws {
+ let base = temporaryDirectory(prefix: "CoordinatedRollback")
+ defer { try? FileManager.default.removeItem(at: base) }
+ let sourceFolder = base.appendingPathComponent("Source", isDirectory: true)
+ let destinationFolder = base.appendingPathComponent("Destination", isDirectory: true)
+ let trash = base.appendingPathComponent("Trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: sourceFolder, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: destinationFolder, withIntermediateDirectories: true)
+ let firstSource = sourceFolder.appendingPathComponent("first.txt")
+ let missingSource = sourceFolder.appendingPathComponent("missing.txt")
+ let firstDestination = destinationFolder.appendingPathComponent("first.txt")
+ let missingDestination = destinationFolder.appendingPathComponent("missing.txt")
+ try Data("first".utf8).write(to: firstSource)
+
+ let coordinator = FileOperationCoordinator(fileOperationEngine: FileOperationEngine(
+ trashDirectoryForTesting: trash
+ ))
+ do {
+ _ = try await coordinator.performTransfers([
+ FileTransferPlan(
+ source: firstSource,
+ destination: firstDestination,
+ move: false,
+ replacesExistingDestination: false
+ ),
+ FileTransferPlan(
+ source: missingSource,
+ destination: missingDestination,
+ move: false,
+ replacesExistingDestination: false
+ )
+ ]) { _ in }
+ throw SelfTestFailure(description: "缺少源文件的批量操作被错误视为成功")
+ } catch let failure as CoordinatedFileOperationFailure {
+ try check(failure.rollbackSucceeded, "批量失败后没有完成自动回滚")
+ try check(!FileManager.default.fileExists(atPath: firstDestination.path), "批量失败后留下已复制项目")
+ try check(FileManager.default.fileExists(atPath: firstSource.path), "回滚错误修改了复制来源")
+ }
+ }
+
+ /// 覆盖 App 真正使用的异步入口,而不只测试底层协调器。
+ private static func testModelAsynchronousImport() async throws {
+ let fixture = try makeFixture(itemCount: 1, fileOperationsAsynchronously: true)
+ defer { fixture.cleanup() }
+ let sourceFolder = fixture.base.appendingPathComponent("External", isDirectory: true)
+ try FileManager.default.createDirectory(at: sourceFolder, withIntermediateDirectories: true)
+ let sources = (0..<3).map { sourceFolder.appendingPathComponent("incoming-\($0).txt") }
+ for source in sources { try Data(source.lastPathComponent.utf8).write(to: source) }
+
+ try await waitForOperationHistory(in: fixture.model)
+ fixture.model.importFiles(sources)
+ try check(fixture.model.fileOperationProgress != nil, "异步导入没有立即显示进度")
+
+ let deadline = Date().addingTimeInterval(5)
+ while fixture.model.fileOperationProgress != nil, Date() < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try check(fixture.model.fileOperationProgress == nil, "异步导入在期限内没有完成")
+ try check(sources.allSatisfy {
+ FileManager.default.fileExists(atPath: fixture.folder.appendingPathComponent($0.lastPathComponent).path)
+ }, "异步导入缺少目标文件")
+ let latest = try require(fixture.model.operationRecords.first, "异步导入没有操作记录")
+ try check(latest.state == .applied, "异步导入记录没有落为已完成")
+ try check(latest.actions.count == sources.count, "异步导入没有逐文件落账")
+ }
+
+ private static func testBackgroundShortFileOperations() async throws {
+ let fixture = try makeFixture(itemCount: 1, fileOperationsAsynchronously: true)
+ defer { fixture.cleanup() }
+
+ try await waitForOperationHistory(in: fixture.model)
+ fixture.model.createFolder()
+ try await waitForFileOperation(in: fixture.model)
+ try check(
+ FileManager.default.fileExists(atPath: fixture.folder.appendingPathComponent("新建文件夹").path),
+ "后台新建文件夹没有创建真实目录"
+ )
+
+ let item = try require(fixture.model.items.first(where: { $0.name.hasSuffix(".txt") }), "后台操作测试文件缺失")
+ fixture.model.toggleTag("红色\n6", for: item)
+ try await waitForFileOperation(in: fixture.model)
+ let tags = try item.url.resourceValues(forKeys: [.tagNamesKey]).tagNames ?? []
+ try check(tags.contains("红色"), "后台标签操作没有写入 Finder 标签:\(tags)")
+
+ fixture.model.rename(item, to: "renamed.txt")
+ try await waitForFileOperation(in: fixture.model)
+ try check(
+ FileManager.default.fileExists(atPath: fixture.folder.appendingPathComponent("renamed.txt").path),
+ "后台重命名没有生成目标文件"
+ )
+
+ try check(
+ fixture.model.operationRecords.suffix(3).allSatisfy { $0.state == .applied },
+ "短文件操作没有全部落为已完成"
+ )
+ }
+
+ private static func testRecoveryAnalyzerEvidence() async throws {
+ let base = temporaryDirectory(prefix: "RecoveryAnalyzer")
+ defer { try? FileManager.default.removeItem(at: base) }
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ let created = base.appendingPathComponent("created.txt")
+ try Data("created".utf8).write(to: created)
+ let applied = OperationRecord(
+ category: .file,
+ kind: .createDocument,
+ summary: "新建文件",
+ state: .unavailable,
+ actions: [.materialize(MaterializeAction(destinationPath: created.path))]
+ )
+ let original = base.appendingPathComponent("original.txt")
+ let destination = base.appendingPathComponent("destination.txt")
+ try Data().write(to: original)
+ try Data().write(to: destination)
+ let ambiguous = OperationRecord(
+ category: .file,
+ kind: .rename,
+ summary: "重命名",
+ state: .unavailable,
+ actions: [.relocate(RelocateAction(
+ originalPath: original.path,
+ destinationPath: destination.path
+ ))]
+ )
+ let empty = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "尚未开始",
+ state: .pending
+ )
+ let partialTarget = base.appendingPathComponent("partial.txt")
+ try Data().write(to: partialTarget)
+ let partial = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "批量复制到一半",
+ itemNames: ["partial.txt", "not-yet-copied.txt"],
+ state: .pending,
+ actions: [.materialize(MaterializeAction(destinationPath: partialTarget.path))]
+ )
+ let disappeared = OperationRecord(
+ category: .file,
+ kind: .createDocument,
+ summary: "目标不明原因消失",
+ state: .undoing,
+ actions: [.materialize(MaterializeAction(destinationPath: base.appendingPathComponent("missing.txt").path))]
+ )
+ let cases = await RecoveryAnalyzer().analyze(records: [applied, ambiguous, empty, partial, disappeared])
+ try check(cases.first(where: { $0.recordID == applied.id })?.suggestedOutcome == .applied, "存在的创建目标没有识别为已完成")
+ try check(cases.first(where: { $0.recordID == ambiguous.id })?.suggestedOutcome == .manualReview, "原路径和目标同时存在时错误自动判断")
+ try check(cases.first(where: { $0.recordID == empty.id })?.suggestedOutcome == .manualReview, "无已提交步骤的 pending 记录被不安全地建议自动存档")
+ try check(cases.first(where: { $0.recordID == partial.id })?.suggestedOutcome == .manualReview, "部分批量步骤被误判为整批完成")
+ try check(cases.first(where: { $0.recordID == disappeared.id })?.suggestedOutcome == .manualReview, "缺少废纸篓证据时误判为已回滚")
+ }
+
+ private static func waitForFileOperation(
+ in model: FolderCanvasModel,
+ timeout: TimeInterval = 5
+ ) async throws {
+ let deadline = Date().addingTimeInterval(timeout)
+ // 准备任务和执行任务之间会短暂切换 Task,但进度状态始终连续存在。
+ while model.fileOperationProgress != nil, Date() < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try check(model.fileOperationProgress == nil, "后台文件操作在期限内没有完成")
+ }
+
+ private static func waitForOperationHistory(
+ in model: FolderCanvasModel,
+ timeout: TimeInterval = 5
+ ) async throws {
+ let deadline = Date().addingTimeInterval(timeout)
+ while model.isLoadingOperationHistory, Date() < deadline {
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ try check(!model.isLoadingOperationHistory, "操作记录在期限内没有后台加载完成")
+ }
+
+ private static func testModelCreateFolderUndoRedo() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let created = fixture.folder.appendingPathComponent("新建文件夹")
+ fixture.model.createFolder()
+ try check(FileManager.default.fileExists(atPath: created.path), "App 没有创建真实文件夹")
+ try check(fixture.model.operationRecords.last?.kind == .createFolder, "新建文件夹没有记录")
+ fixture.model.undoLastAction()
+ try check(!FileManager.default.fileExists(atPath: created.path), "Command+Z 没有撤销新建文件夹")
+ fixture.model.redoLastAction()
+ try check(FileManager.default.fileExists(atPath: created.path), "Shift+Command+Z 没有重做新建文件夹")
+ }
+
+ private static func testModelRenameUndoRedo() throws {
+ let fixture = try makeFixture(itemCount: 1)
+ defer { fixture.cleanup() }
+ let item = try require(fixture.model.items.first, "没有测试文件")
+ let expectedPosition = try require(fixture.model.positions[item.id], "没有原始位置")
+ let renamed = item.url.deletingLastPathComponent().appendingPathComponent("renamed.txt")
+ fixture.model.rename(item, to: renamed.lastPathComponent)
+ try check(FileManager.default.fileExists(atPath: renamed.path), "App 重命名没有生效")
+ try check(
+ fixture.model.positions[renamed.path] == expectedPosition,
+ "重命名后画布位置丢失:期望 \(expectedPosition),实际 \(fixture.model.positions)"
+ )
+ fixture.model.undoLastAction()
+ try check(FileManager.default.fileExists(atPath: item.url.path), "撤销重命名没有恢复原路径")
+ try check(fixture.model.positions[item.id] == expectedPosition, "撤销重命名没有恢复位置")
+ fixture.model.redoLastAction()
+ try check(FileManager.default.fileExists(atPath: renamed.path), "重做重命名没有恢复新路径")
+ }
+
+ private static func testModelDuplicateUndoRedo() throws {
+ let fixture = try makeFixture(itemCount: 1)
+ defer { fixture.cleanup() }
+ let item = try require(fixture.model.items.first, "没有测试文件")
+ let duplicate = fixture.folder.appendingPathComponent("item-000 2.txt")
+ fixture.model.duplicate(item)
+ try check(FileManager.default.fileExists(atPath: duplicate.path), "制作副本没有创建真实文件")
+ fixture.model.undoLastAction()
+ try check(!FileManager.default.fileExists(atPath: duplicate.path), "撤销制作副本没有移除副本")
+ fixture.model.redoLastAction()
+ try check(FileManager.default.fileExists(atPath: duplicate.path), "重做制作副本没有恢复副本")
+ }
+
+ private static func testModelTrashUndoRestoresLayout() throws {
+ let fixture = try makeFixture(itemCount: 1)
+ defer { fixture.cleanup() }
+ let item = try require(fixture.model.items.first, "没有测试文件")
+ fixture.model.move(item, to: CGPoint(x: 650, y: 430))
+ let expected = try require(fixture.model.positions[item.id], "没有移动后位置")
+ fixture.model.trash(item)
+ try check(!FileManager.default.fileExists(atPath: item.url.path), "真实文件没有移至废纸篓")
+ fixture.model.undoLastAction()
+ try check(FileManager.default.fileExists(atPath: item.url.path), "撤销没有恢复真实文件")
+ try check(fixture.model.positions[item.id] == expected, "撤销删除没有恢复图标位置")
+ fixture.model.redoLastAction()
+ try check(!FileManager.default.fileExists(atPath: item.url.path), "重做没有再次移至废纸篓")
+ }
+
+ private static func testImportReplaceUndoRedo() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let target = fixture.folder.appendingPathComponent("report.txt")
+ let incomingFolder = fixture.base.appendingPathComponent("Incoming", isDirectory: true)
+ try FileManager.default.createDirectory(at: incomingFolder, withIntermediateDirectories: true)
+ let source = incomingFolder.appendingPathComponent("report.txt")
+ try Data("old".utf8).write(to: target)
+ try Data("new".utf8).write(to: source)
+ fixture.model.refreshItems()
+ fixture.model.importFiles([source])
+ try check(fixture.model.pendingConflict != nil, "导入冲突没有请求选择")
+ fixture.model.resolvePendingConflict(.replace)
+ let replaced = try String(contentsOf: target, encoding: .utf8)
+ try check(replaced == "new", "替换没有写入新文件")
+ fixture.model.undoLastAction()
+ let restored = try String(contentsOf: target, encoding: .utf8)
+ try check(restored == "old", "撤销替换没有恢复原文件")
+ fixture.model.redoLastAction()
+ let redone = try String(contentsOf: target, encoding: .utf8)
+ try check(redone == "new", "重做替换没有恢复新文件")
+ }
+
+ private static func testFileUndoSurvivesRestart() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let created = fixture.folder.appendingPathComponent("新建文件夹")
+ fixture.model.createFolder()
+ let reopened = FolderCanvasModel(
+ layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
+ userDefaults: fixture.defaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
+ )
+ reopened.open(folder: fixture.folder)
+ try check(reopened.canUndo, "重启后文件操作不能撤销")
+ reopened.undoLastAction()
+ try check(!FileManager.default.fileExists(atPath: created.path), "重启后没有撤销新建操作")
+ }
+
+ private static func testFileUndoAfterRootFolderMove() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ fixture.model.createFolder()
+ let movedFolder = fixture.base.appendingPathComponent("Moved Root", isDirectory: true)
+ try FileManager.default.moveItem(at: fixture.folder, to: movedFolder)
+ let movedCreated = movedFolder.appendingPathComponent("新建文件夹")
+ let reopened = FolderCanvasModel(
+ layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
+ userDefaults: fixture.defaults,
+ autoOpenLastFolder: true,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
+ )
+ try check(reopened.folderURL == movedFolder.standardizedFileURL, "根文件夹移动后没有自动恢复")
+ reopened.undoLastAction()
+ try check(!FileManager.default.fileExists(atPath: movedCreated.path), "根文件夹移动后文件撤销仍使用旧路径")
+ }
+
+ private static func testUnifiedUndoOrdering() throws {
+ let fixture = try makeFixture(itemCount: 1)
+ defer { fixture.cleanup() }
+ let item = try require(fixture.model.items.first, "没有测试文件")
+ let original = try require(fixture.model.positions[item.id], "没有初始位置")
+ fixture.model.move(item, to: CGPoint(x: 700, y: 500))
+ let moved = try require(fixture.model.positions[item.id], "没有移动位置")
+ let created = fixture.folder.appendingPathComponent("新建文件夹")
+ fixture.model.createFolder()
+
+ fixture.model.undoLastAction()
+ try check(!FileManager.default.fileExists(atPath: created.path), "统一撤销没有先撤销最新文件操作")
+ try check(fixture.model.positions[item.id] == moved, "撤销文件操作时误改了布局")
+ fixture.model.undoLastAction()
+ try check(fixture.model.positions[item.id] == original, "第二次撤销没有回退布局")
+
+ fixture.model.redoLastAction()
+ try check(fixture.model.positions[item.id] == moved, "统一重做没有先恢复布局")
+ fixture.model.redoLastAction()
+ try check(FileManager.default.fileExists(atPath: created.path), "统一重做没有再恢复文件操作")
+ }
+
+ private static func testLayoutUndoDepthMarksViewOnly() throws {
+ let fixture = try makeFixture(itemCount: 1)
+ defer { fixture.cleanup() }
+ let item = try require(fixture.model.items.first, "没有测试文件")
+ for index in 0..<51 {
+ fixture.model.setScale(index.isMultiple(of: 2) ? 1.0 : 1.25, for: item)
+ }
+ let layoutRecords = fixture.model.operationRecords.filter { $0.category == .layout }
+ try check(layoutRecords.count == 51, "布局历史数量不正确")
+ try check(layoutRecords.first?.state == .viewOnly, "超出撤销深度的布局仍标记为可撤销")
+ try check(layoutRecords.filter { $0.state == .applied }.count == 50, "可撤销布局数量没有限制为 50")
+ }
+
+ private static func testRenameConflictRequiresChoice() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let first = fixture.folder.appendingPathComponent("alpha")
+ let existing = fixture.folder.appendingPathComponent("beta")
+ try Data("alpha".utf8).write(to: first)
+ try Data("beta".utf8).write(to: existing)
+ fixture.model.refreshItems()
+ let item = try require(fixture.model.items.first(where: { $0.name == "alpha" }), "没有 alpha")
+ fixture.model.rename(item, to: "beta")
+ try check(fixture.model.pendingConflict != nil, "重命名冲突没有请求用户选择")
+ try check(FileManager.default.fileExists(atPath: first.path), "选择前就改动了源文件")
+ try check(FileManager.default.fileExists(atPath: existing.path), "选择前就覆盖了现有文件")
+ fixture.model.resolvePendingConflict(.keepBoth)
+ try check(FileManager.default.fileExists(atPath: fixture.folder.appendingPathComponent("beta 2").path), "保留两者没有创建唯一名称")
+ try check(FileManager.default.fileExists(atPath: existing.path), "保留两者覆盖了原文件")
+ }
+
+ private static func testDiagnosticsPrivacy() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let secretName = "秘密客户-张三-项目.xlsx"
+ try Data("secret".utf8).write(to: fixture.folder.appendingPathComponent(secretName))
+ fixture.model.refreshItems()
+ let item = try require(fixture.model.items.first, "诊断测试文件缺失")
+ fixture.model.move(item, to: CGPoint(x: 600, y: 400))
+ let data = try fixture.model.diagnosticsData()
+ let text = try require(String(data: data, encoding: .utf8), "诊断 JSON 不是 UTF-8")
+ try check(!text.contains(secretName), "诊断信息泄露了文件名")
+ try check(!text.contains(fixture.folder.path), "诊断信息泄露了完整路径")
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let decoded = try decoder.decode(PrivacySafeDiagnostics.self, from: data)
+ try check(decoded.visibleItemCount == 1, "诊断项目数量不正确")
+ }
+
+ private static func testPendingOperationMarkedUnavailableOnRestart() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let exportURL = fixture.base.appendingPathComponent("layout.json")
+ try fixture.model.exportLayout(to: exportURL)
+ let canvas = try JSONDecoder().decode(SavedCanvas.self, from: Data(contentsOf: exportURL))
+ let rootID = try require(canvas.rootResourceID, "布局没有根标识")
+ let key = fixture.store.canvasKey(for: rootID)
+ let pending = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "未完成导入",
+ state: .pending,
+ actions: [.materialize(MaterializeAction(destinationPath: fixture.folder.appendingPathComponent("pending.txt").path))]
+ )
+ try fixture.operationStore.save(OperationHistoryDocument(records: [pending]), canvasKey: key)
+ let reopened = FolderCanvasModel(
+ layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
+ userDefaults: fixture.defaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
+ )
+ reopened.open(folder: fixture.folder)
+ try check(reopened.operationRecords.first?.state == .unavailable, "未完成操作没有标记为需核对")
+ let persisted = try fixture.operationStore.load(canvasKey: key)
+ try check(persisted.records.first?.state == .unavailable, "需核对状态没有持久化")
+ }
+
+ private static func testCorruptHistoryBlocksModelMutations() throws {
+ let fixture = try makeFixture(itemCount: 0)
+ defer { fixture.cleanup() }
+ let exportURL = fixture.base.appendingPathComponent("layout.json")
+ try fixture.model.exportLayout(to: exportURL)
+ let canvas = try JSONDecoder().decode(SavedCanvas.self, from: Data(contentsOf: exportURL))
+ let rootID = try require(canvas.rootResourceID, "布局没有根标识")
+ let key = fixture.store.canvasKey(for: rootID)
+ let historyURL = fixture.operationStore.historyURL(canvasKey: key)
+ try FileManager.default.createDirectory(at: historyURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try Data("broken".utf8).write(to: historyURL)
+ let reopened = FolderCanvasModel(
+ layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
+ userDefaults: fixture.defaults,
+ autoOpenLastFolder: false,
+ initialCanvasSize: CGSize(width: 1024, height: 768),
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
+ )
+ reopened.open(folder: fixture.folder)
+ try check(reopened.operationHistoryIsBlocked, "App 没有识别损坏操作记录")
+ reopened.createFolder()
+ try check(
+ !FileManager.default.fileExists(atPath: fixture.folder.appendingPathComponent("新建文件夹").path),
+ "操作记录损坏时仍修改了真实文件"
+ )
+ }
+
private static func testSeventyItemOverflow() throws {
let fixture = try makeFixture(itemCount: 70, canvasSize: CGSize(width: 1728, height: 1117))
defer { fixture.cleanup() }
@@ -163,6 +1215,85 @@ struct SpatialFolderSelfTests {
try assertItemsInsideBounds(fixture.model)
}
+ private static func testBatchInboxPlacement() throws {
+ let fixture = try makeFixture(itemCount: 6)
+ defer { fixture.cleanup() }
+ let targets = Array(fixture.model.items.prefix(3))
+ fixture.model.moveToInbox(targets)
+ try check(targets.allSatisfy { fixture.model.inboxIDs.contains($0.id) }, "批量移入待放置区不完整")
+ fixture.model.placeFromInbox(targets)
+ try check(targets.allSatisfy { !fixture.model.inboxIDs.contains($0.id) }, "批量放回主画布不完整")
+ try check(targets.allSatisfy { fixture.model.positions[$0.id] != nil }, "批量放回没有生成位置")
+ }
+
+ private static func testDropLocationPlacement() throws {
+ let engine = CanvasLayoutEngine()
+ let size = CGSize(width: 1_200, height: 800)
+ let existing = [
+ CanvasLayoutItem(id: "existing-a", scale: 1.25),
+ CanvasLayoutItem(id: "existing-b", scale: 1.25)
+ ]
+ let before = [
+ "existing-a": CanvasPoint(x: 120, y: 120),
+ "existing-b": CanvasPoint(x: 360, y: 120)
+ ]
+ let imported = [
+ CanvasLayoutItem(id: "new-a", scale: 1.25),
+ CanvasLayoutItem(id: "new-b", scale: 1.25)
+ ]
+ let dropPoint = CGPoint(x: 900, y: 600)
+ let result = engine.placeImportedItems(
+ imported,
+ near: dropPoint,
+ existingItems: existing,
+ positions: before,
+ inboxIDs: [],
+ canvasSize: size
+ )
+ try check(result.positions["existing-a"] == before["existing-a"], "拖入时移动了第一个已有项目")
+ try check(result.positions["existing-b"] == before["existing-b"], "拖入时移动了第二个已有项目")
+ let first = try require(result.positions["new-a"], "第一个拖入项目没有落位")
+ let second = try require(result.positions["new-b"], "第二个拖入项目没有落位")
+ try check(first != second, "多个拖入项目重叠")
+ try check(abs(first.x - dropPoint.x) < 250 && abs(first.y - dropPoint.y) < 250, "首个项目没有放在落点附近")
+ }
+
+ private static func testGroupDragPreservesRelativeLayout() throws {
+ let fixture = try makeFixture(itemCount: 2, canvasSize: CGSize(width: 1200, height: 800))
+ defer { fixture.cleanup() }
+ let first = fixture.model.items[0]
+ let second = fixture.model.items[1]
+ fixture.model.move(first, to: CGPoint(x: 240, y: 240))
+ fixture.model.move(second, to: CGPoint(x: 480, y: 360))
+ let firstBefore = try require(fixture.model.positions[first.id], "第一个项目没有位置")
+ let secondBefore = try require(fixture.model.positions[second.id], "第二个项目没有位置")
+ fixture.model.select(first, extendingSelection: false)
+ fixture.model.select(second, extendingSelection: true)
+ fixture.model.beginDragging(first)
+ fixture.model.updateDrag(translation: CGSize(width: 2_000, height: 2_000))
+ fixture.model.finishDrag()
+ let firstAfter = try require(fixture.model.positions[first.id], "拖动后第一个项目没有位置")
+ let secondAfter = try require(fixture.model.positions[second.id], "拖动后第二个项目没有位置")
+ try check(
+ secondAfter.x - firstAfter.x == secondBefore.x - firstBefore.x
+ && secondAfter.y - firstAfter.y == secondBefore.y - firstBefore.y,
+ "整体拖动改变了组内相对位置"
+ )
+ try assertItemsInsideBounds(fixture.model)
+ }
+
+ private static func testBatchDuplicateAndTrash() throws {
+ let fixture = try makeFixture(itemCount: 3)
+ defer { fixture.cleanup() }
+ let targets = Array(fixture.model.items.prefix(2))
+ fixture.model.duplicate(targets)
+ try check(fixture.model.items.count == 5, "批量制作副本没有生成两个项目")
+ fixture.model.trash(targets)
+ try check(targets.allSatisfy { !FileManager.default.fileExists(atPath: $0.url.path) }, "批量废纸篓仍保留原文件")
+ try check(fixture.model.operationRecords.last?.kind == .trash, "批量废纸篓没有生成统一记录")
+ try check(fixture.model.operationRecords.last?.actions.count == 2, "批量废纸篓没有逐项记录")
+ }
+
private static func testExternalRenameKeepsPosition() throws {
let fixture = try makeFixture(itemCount: 1)
defer { fixture.cleanup() }
@@ -203,10 +1334,15 @@ struct SpatialFolderSelfTests {
fixture.model.setLocked(true)
let reopened = FolderCanvasModel(
layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
userDefaults: fixture.defaults,
autoOpenLastFolder: false,
initialCanvasSize: CGSize(width: 1024, height: 768),
- monitorFolders: false
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
)
reopened.open(folder: fixture.folder)
try check(reopened.isLocked, "重新打开文件夹后锁定状态丢失")
@@ -251,6 +1387,92 @@ struct SpatialFolderSelfTests {
try check(abs(iconWidth / spacing - 104.0 / 320.0) < 0.000_001, "图标和间距比例不一致")
}
+ private static func testPresentationCoversTallerViewport() throws {
+ let logical = CGSize(width: 2560, height: 1440)
+ let viewport = CGSize(width: 2048, height: 1300)
+ let scale: CGFloat = 0.8
+ let presentation = CanvasViewport.presentationSize(
+ logicalSize: logical,
+ viewportSize: viewport,
+ displayScale: scale
+ )
+ try check(abs(presentation.width * scale - viewport.width) < 0.001, "背景没有覆盖视口宽度")
+ try check(abs(presentation.height * scale - viewport.height) < 0.001, "背景没有覆盖全屏额外高度")
+ try check(presentation.height > logical.height, "较高视口没有扩展显示背景")
+ }
+
+ private static func testPresentationNeverShrinksLogicalCanvas() throws {
+ let logical = CGSize(width: 2560, height: 1440)
+ let presentation = CanvasViewport.presentationSize(
+ logicalSize: logical,
+ viewportSize: CGSize(width: 1200, height: 700),
+ displayScale: 0.8
+ )
+ try check(presentation == logical, "较小视口缩短了逻辑画布")
+ }
+
+ private static func testWindowAspectTracksWidth() throws {
+ let size = WindowAspectSizing.constrainedContentSize(
+ proposedSize: CGSize(width: 1280, height: 700),
+ currentSize: CGSize(width: 1000, height: 700),
+ canvasSize: CGSize(width: 1920, height: 1080),
+ fixedChromeHeight: 56
+ )
+ try check(abs(size.width - 1280) < 0.01, "窗口宽度没有跟随拖动")
+ try check(abs(size.height - 776) < 0.01, "没有在画布比例之外保留固定工具栏高度")
+ }
+
+ private static func testWindowAspectTracksHeight() throws {
+ let size = WindowAspectSizing.constrainedContentSize(
+ proposedSize: CGSize(width: 1000, height: 900),
+ currentSize: CGSize(width: 1000, height: 700),
+ canvasSize: CGSize(width: 1920, height: 1080),
+ fixedChromeHeight: 56
+ )
+ try check(abs(size.width - 1500.4444) < 0.01, "窗口宽度没有跟随高度变化")
+ try check(abs(size.height - 900) < 0.01, "窗口高度没有跟随拖动")
+ }
+
+ private static func testWindowAspectFitsCurrentDisplay() throws {
+ let size = WindowAspectSizing.constrainedContentSize(
+ proposedSize: CGSize(width: 2400, height: 1400),
+ currentSize: CGSize(width: 1200, height: 731),
+ canvasSize: CGSize(width: 1920, height: 1080),
+ fixedChromeHeight: 56,
+ minimumSize: CGSize(width: 900, height: 620),
+ maximumSize: CGSize(width: 1440, height: 850)
+ )
+ try check(size.width <= 1440.01, "窗口宽度超出显示器")
+ try check(size.height <= 850.01, "窗口高度超出显示器")
+ let canvasRatio = size.width / (size.height - 56)
+ try check(abs(canvasRatio - (1920.0 / 1080.0)) < 0.0001, "限制尺寸后画布比例改变")
+ }
+
+ private static func testMaximumWindowBypassesAspectConstraint() throws {
+ let visibleFrame = CGSize(width: 1920, height: 1080)
+ try check(
+ WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: visibleFrame,
+ visibleFrameSize: visibleFrame
+ ),
+ "完整可见区域没有被识别为最大化"
+ )
+ try check(
+ WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: CGSize(width: 1919, height: 1079),
+ visibleFrameSize: visibleFrame
+ ),
+ "系统舍入后的最大化尺寸没有被识别"
+ )
+ try check(
+ !WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: CGSize(width: 1720, height: 1080),
+ visibleFrameSize: visibleFrame
+ ),
+ "仅达到最大高度的普通窗口被误判为最大化"
+ )
+ }
+
private static func testReferenceCanvasResetAndUndo() throws {
let internalDisplay = CGSize(width: 1512, height: 982)
let external = CGSize(width: 2560, height: 1440)
@@ -284,10 +1506,15 @@ struct SpatialFolderSelfTests {
let reopened = FolderCanvasModel(
layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
userDefaults: fixture.defaults,
autoOpenLastFolder: false,
initialCanvasSize: internalDisplay,
- monitorFolders: false
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
)
reopened.open(folder: fixture.folder)
let reopenedItem = try require(reopened.items.first, "重启后项目缺失")
@@ -313,10 +1540,15 @@ struct SpatialFolderSelfTests {
let migrated = FolderCanvasModel(
layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
userDefaults: fixture.defaults,
autoOpenLastFolder: false,
initialCanvasSize: external,
- monitorFolders: false
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
)
migrated.open(folder: fixture.folder)
let migratedItem = try require(migrated.items.first, "迁移后项目缺失")
@@ -354,10 +1586,15 @@ struct SpatialFolderSelfTests {
try FileManager.default.moveItem(at: fixture.folder, to: movedFolder)
let reopened = FolderCanvasModel(
layoutStore: fixture.store,
+ operationStore: fixture.operationStore,
+ fileOperationEngine: fixture.fileOperationEngine,
userDefaults: fixture.defaults,
autoOpenLastFolder: true,
initialCanvasSize: CGSize(width: 1024, height: 768),
- monitorFolders: false
+ monitorFolders: false,
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: false
)
defer { fixture.cleanup() }
try check(reopened.folderURL?.standardizedFileURL == movedFolder.standardizedFileURL, "移动后没有恢复新路径")
@@ -398,7 +1635,8 @@ struct SpatialFolderSelfTests {
private static func makeFixture(
itemCount: Int,
- canvasSize: CGSize = CGSize(width: 1024, height: 768)
+ canvasSize: CGSize = CGSize(width: 1024, height: 768),
+ fileOperationsAsynchronously: Bool = false
) throws -> ModelFixture {
let base = temporaryDirectory(prefix: "Model")
let folder = base.appendingPathComponent("Root", isDirectory: true)
@@ -412,18 +1650,30 @@ struct SpatialFolderSelfTests {
let defaults = try require(UserDefaults(suiteName: suite), "无法创建测试偏好设置")
defaults.removePersistentDomain(forName: suite)
let store = CanvasLayoutStore(layoutsDirectory: layouts, maximumBackups: 12)
+ let operationStore = OperationHistoryStore(directory: base.appendingPathComponent("Operations", isDirectory: true))
+ let fileOperationEngine = FileOperationEngine(
+ trashDirectoryForTesting: base.appendingPathComponent("Trash", isDirectory: true)
+ )
let model = FolderCanvasModel(
layoutStore: store,
+ operationStore: operationStore,
+ fileOperationEngine: fileOperationEngine,
userDefaults: defaults,
autoOpenLastFolder: false,
initialCanvasSize: canvasSize,
- monitorFolders: false
+ monitorFolders: false,
+ sessionLockDirectory: base.appendingPathComponent("Locks", isDirectory: true),
+ sessionLockingEnabled: false,
+ scansAsynchronously: false,
+ fileOperationsAsynchronously: fileOperationsAsynchronously
)
model.open(folder: folder)
return ModelFixture(
base: base,
folder: folder,
store: store,
+ operationStore: operationStore,
+ fileOperationEngine: fileOperationEngine,
defaults: defaults,
defaultsSuite: suite,
model: model
@@ -434,6 +1684,7 @@ struct SpatialFolderSelfTests {
FileManager.default.temporaryDirectory
.appendingPathComponent("SpatialFolder\(prefix)Tests-\(UUID().uuidString)", isDirectory: true)
}
+
}
@MainActor
@@ -441,6 +1692,8 @@ private struct ModelFixture {
let base: URL
let folder: URL
let store: CanvasLayoutStore
+ let operationStore: OperationHistoryStore
+ let fileOperationEngine: FileOperationEngine
let defaults: UserDefaults
let defaultsSuite: String
let model: FolderCanvasModel
@@ -450,3 +1703,18 @@ private struct ModelFixture {
defaults.removePersistentDomain(forName: defaultsSuite)
}
}
+
+/// 线程安全地收集后台协调器事件,供异步自测检查逐步骤记录。
+private actor OperationEventCollector {
+ private var events: [CoordinatedFileOperationEvent] = []
+
+ func append(_ event: CoordinatedFileOperationEvent) {
+ events.append(event)
+ }
+
+ func appliedActionCount() -> Int {
+ events.reduce(into: 0) { count, event in
+ if case .didApply = event { count += 1 }
+ }
+ }
+}
diff --git a/Tests/SpatialFolderTests/SpatialFolderTests.swift b/Tests/SpatialFolderTests/SpatialFolderTests.swift
new file mode 100644
index 0000000..ef1ee50
--- /dev/null
+++ b/Tests/SpatialFolderTests/SpatialFolderTests.swift
@@ -0,0 +1,178 @@
+import Foundation
+import Testing
+@testable import SpatialFolder
+
+@Suite("空间文件夹 2.5 核心回归")
+struct SpatialFolderTests {
+ @Test("画布锁只允许一个写入者")
+ func testSessionLockIsExclusive() throws {
+ let directory = temporaryDirectory("Locks")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let first = try CanvasSessionLock.acquire(
+ canvasKey: "test-canvas",
+ directory: directory,
+ processID: 100,
+ appVersion: "2.5.0"
+ )
+ guard case let .acquired(lock) = first else {
+ Issue.record("第一个会话没有取得锁")
+ return
+ }
+ let second = try CanvasSessionLock.acquire(
+ canvasKey: "test-canvas",
+ directory: directory,
+ processID: 200,
+ appVersion: "2.5.0"
+ )
+ guard case let .occupied(owner) = second else {
+ Issue.record("第二个会话错误地取得了写入权")
+ return
+ }
+ #expect(owner?.processID == 100)
+ lock.release()
+ guard case .acquired = try CanvasSessionLock.acquire(
+ canvasKey: "test-canvas",
+ directory: directory,
+ processID: 300,
+ appVersion: "2.5.0"
+ ) else {
+ Issue.record("原会话释放后仍无法取得锁")
+ return
+ }
+ }
+
+ @Test("3000 项扫描保持在性能预算内")
+ func testLargeFirstLevelScan() throws {
+ let folder = temporaryDirectory("Scan")
+ defer { try? FileManager.default.removeItem(at: folder) }
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ for index in 0..<3_000 {
+ let url = folder.appendingPathComponent(String(format: "item-%04d.txt", index))
+ _ = FileManager.default.createFile(atPath: url.path, contents: Data())
+ }
+ let started = Date()
+ let entries = try FolderDirectoryScanner().scan(folder: folder)
+ #expect(entries.count == 3_000)
+ #expect(Date().timeIntervalSince(started) < 10)
+ }
+
+ @Test("批量复制失败会回滚本批次")
+ func testCoordinatedTransferRollsBack() async throws {
+ let root = temporaryDirectory("Coordinator")
+ defer { try? FileManager.default.removeItem(at: root) }
+ let source = root.appendingPathComponent("source", isDirectory: true)
+ let target = root.appendingPathComponent("target", isDirectory: true)
+ let trash = root.appendingPathComponent("trash", isDirectory: true)
+ try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
+ let valid = source.appendingPathComponent("valid.txt")
+ let missing = source.appendingPathComponent("missing.txt")
+ try Data("valid".utf8).write(to: valid)
+ let plans = [
+ FileTransferPlan(
+ source: valid,
+ destination: target.appendingPathComponent("valid.txt"),
+ move: false,
+ replacesExistingDestination: false
+ ),
+ FileTransferPlan(
+ source: missing,
+ destination: target.appendingPathComponent("missing.txt"),
+ move: false,
+ replacesExistingDestination: false
+ )
+ ]
+ do {
+ _ = try await FileOperationCoordinator(fileOperationEngine: FileOperationEngine(
+ trashDirectoryForTesting: trash
+ )).performTransfers(plans) { _ in }
+ Issue.record("缺失来源没有触发失败")
+ } catch let failure as CoordinatedFileOperationFailure {
+ #expect(failure.rollbackSucceeded)
+ #expect(!FileManager.default.fileExists(
+ atPath: target.appendingPathComponent("valid.txt").path
+ ))
+ }
+ }
+
+ @Test("增量日志重放最终状态")
+ func testIncrementalJournalReplaysLatestState() async throws {
+ let directory = temporaryDirectory("Journal")
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let store = OperationJournalStore(diskStore: OperationJournalDiskStore(
+ directory: directory,
+ compactionEventThreshold: 50
+ ))
+ var record = OperationRecord(
+ category: .file,
+ kind: .copyItems,
+ summary: "导入",
+ state: .pending
+ )
+ _ = try await store.upsert(record, canvasKey: "canvas")
+ record.state = .applied
+ record.actions = [.materialize(MaterializeAction(destinationPath: "/tmp/result"))]
+ _ = try await store.upsert(record, canvasKey: "canvas")
+ let loaded = try await store.load(canvasKey: "canvas")
+ #expect(loaded.records.count == 1)
+ #expect(loaded.records.first?.state == .applied)
+ #expect(loaded.records.first?.actions == record.actions)
+ }
+
+ @Test("投放点布局不移动已有项目")
+ func testDropPlacementPreservesExistingItems() {
+ let engine = CanvasLayoutEngine()
+ let before = ["existing": CanvasPoint(x: 120, y: 120)]
+ let result = engine.placeImportedItems(
+ [CanvasLayoutItem(id: "incoming", scale: 1.25)],
+ near: CGPoint(x: 800, y: 500),
+ existingItems: [CanvasLayoutItem(id: "existing", scale: 1.25)],
+ positions: before,
+ inboxIDs: [],
+ canvasSize: CGSize(width: 1_000, height: 700)
+ )
+ #expect(result.positions["existing"] == before["existing"])
+ #expect(result.positions["incoming"] != nil)
+ }
+
+ @Test("普通窗口随宽度保持比例")
+ func testNormalWindowKeepsAspectRatio() {
+ let result = WindowAspectSizing.constrainedContentSize(
+ proposedSize: CGSize(width: 1_000, height: 600),
+ currentSize: CGSize(width: 800, height: 550),
+ canvasSize: CGSize(width: 1_600, height: 900),
+ fixedChromeHeight: 50
+ )
+ #expect(result == CGSize(width: 1_000, height: 612.5))
+ }
+
+ @Test("最大化窗口可绕开普通比例约束")
+ func testMaximumWindowProposalIsRecognized() {
+ #expect(WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: CGSize(width: 1_510, height: 980),
+ visibleFrameSize: CGSize(width: 1_512, height: 982)
+ ))
+ #expect(!WindowAspectSizing.isMaximumFrameProposal(
+ proposedFrameSize: CGSize(width: 1_200, height: 800),
+ visibleFrameSize: CGSize(width: 1_512, height: 982)
+ ))
+ }
+
+ @Test("全屏额外高度由背景覆盖")
+ func testFullScreenPresentationCoversViewport() {
+ let size = CanvasViewport.presentationSize(
+ logicalSize: CGSize(width: 1_600, height: 900),
+ viewportSize: CGSize(width: 1_600, height: 1_050),
+ displayScale: 1
+ )
+ #expect(size == CGSize(width: 1_600, height: 1_050))
+ }
+
+ private func temporaryDirectory(_ suffix: String) -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "SpatialFolderStandardTests-\(suffix)-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ }
+}
diff --git a/config/release.env b/config/release.env
new file mode 100644
index 0000000..63e445e
--- /dev/null
+++ b/config/release.env
@@ -0,0 +1,4 @@
+# 2.4 起所有打包脚本只从这里读取产品版本,避免 App、ZIP 和构建目录各写一份版本号。
+PRODUCT_NAME="空间文件夹"
+MARKETING_VERSION="2.5.0"
+BUILD_NUMBER="250"
diff --git a/scripts/package_app.zsh b/scripts/package_app.zsh
index 8f72b7f..ff1cecd 100755
--- a/scripts/package_app.zsh
+++ b/scripts/package_app.zsh
@@ -2,17 +2,26 @@
set -euo pipefail
ROOT="${0:A:h:h}"
-RELEASE_DIR="$ROOT/Release"
-APP_NAME="空间文件夹 2.1.1"
+source "$ROOT/config/release.env"
+RELEASE_DIR="$ROOT/Release/$MARKETING_VERSION"
+APP_NAME="$PRODUCT_NAME"
APP="$RELEASE_DIR/$APP_NAME.app"
-ZIP="$RELEASE_DIR/空间文件夹-v2.1.1.zip"
-SDK="/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk"
+ZIP="$RELEASE_DIR/$PRODUCT_NAME-v$MARKETING_VERSION.zip"
+SDK="$("$ROOT/scripts/select_macos_sdk.zsh")"
CACHE="$ROOT/.build/module-cache"
-SCRATCH="$ROOT/.build/2.1.1-release"
+SCRATCH="$ROOT/.build/$MARKETING_VERSION-release"
-if [[ ! -d "$SDK" ]]; then
- SDK="$(xcrun --sdk macosx --show-sdk-path)"
-fi
+# 打包前校验唯一版本源与 Info.plist,防止产物名称与内部版本不一致。
+PLIST_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$ROOT/Assets/Info.plist")"
+PLIST_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$ROOT/Assets/Info.plist")"
+[[ "$PLIST_VERSION" == "$MARKETING_VERSION" ]] || {
+ echo "Info.plist 版本 $PLIST_VERSION 与 release.env $MARKETING_VERSION 不一致" >&2
+ exit 1
+}
+[[ "$PLIST_BUILD" == "$BUILD_NUMBER" ]] || {
+ echo "Info.plist 构建号 $PLIST_BUILD 与 release.env $BUILD_NUMBER 不一致" >&2
+ exit 1
+}
mkdir -p "$RELEASE_DIR" "$CACHE"
cd "$ROOT"
@@ -35,7 +44,7 @@ codesign --verify --deep --strict --verbose=2 "$APP"
rm -f "$ZIP"
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
-VERIFY_DIR="$(mktemp -d /tmp/spatial-folder-2.1.1-verify.XXXXXX)"
+VERIFY_DIR="$(mktemp -d /tmp/spatial-folder-$MARKETING_VERSION-verify.XXXXXX)"
trap 'rm -rf "$VERIFY_DIR"' EXIT
ditto -x -k "$ZIP" "$VERIFY_DIR"
codesign --verify --deep --strict --verbose=2 "$VERIFY_DIR/$APP_NAME.app"
diff --git a/scripts/run_dev.zsh b/scripts/run_dev.zsh
index d441a99..b5df285 100755
--- a/scripts/run_dev.zsh
+++ b/scripts/run_dev.zsh
@@ -2,9 +2,9 @@
set -euo pipefail
ROOT="${0:A:h:h}"
-SDK="/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk"
+SDK="$("$ROOT/scripts/select_macos_sdk.zsh")"
CACHE="$ROOT/.build/module-cache"
-SCRATCH="$ROOT/.build/2.1.1"
+SCRATCH="$ROOT/.build/dev"
mkdir -p "$CACHE"
cd "$ROOT"
diff --git a/scripts/run_performance_baseline.zsh b/scripts/run_performance_baseline.zsh
new file mode 100755
index 0000000..4243714
--- /dev/null
+++ b/scripts/run_performance_baseline.zsh
@@ -0,0 +1,25 @@
+#!/bin/zsh
+set -euo pipefail
+
+ROOT="${0:A:h:h}"
+SDK="$("$ROOT/scripts/select_macos_sdk.zsh")"
+SWIFTC="$(xcrun --find swiftc)"
+ARCH="$(uname -m)"
+CACHE="$ROOT/.build/performance-module-cache"
+OUTPUT="$ROOT/.build/SpatialFolderPerformanceBaseline"
+
+mkdir -p "$CACHE"
+SDKROOT="$SDK" CLANG_MODULE_CACHE_PATH="$CACHE" \
+ "$SWIFTC" \
+ -sdk "$SDK" \
+ -target "$ARCH-apple-macosx14.0" \
+ -module-cache-path "$CACHE" \
+ -parse-as-library \
+ "$ROOT/Sources/SpatialFolder/CanvasLayoutStore.swift" \
+ "$ROOT/Sources/SpatialFolder/FolderScanService.swift" \
+ "$ROOT/Sources/SpatialFolder/OperationHistoryStore.swift" \
+ "$ROOT/Sources/SpatialFolder/OperationJournalStore.swift" \
+ "$ROOT/Tests/Performance/PerformanceMain.swift" \
+ -o "$OUTPUT"
+
+"$OUTPUT"
diff --git a/scripts/run_self_tests.zsh b/scripts/run_self_tests.zsh
index 34d4486..3445828 100755
--- a/scripts/run_self_tests.zsh
+++ b/scripts/run_self_tests.zsh
@@ -2,20 +2,33 @@
set -euo pipefail
ROOT="${0:A:h:h}"
-SDK="/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk"
+SDK="$("$ROOT/scripts/select_macos_sdk.zsh")"
+SWIFTC="$(xcrun --find swiftc)"
+ARCH="$(uname -m)"
CACHE="$ROOT/.build/self-tests-module-cache"
OUTPUT="$ROOT/.build/SpatialFolderSelfTests"
mkdir -p "$CACHE"
SDKROOT="$SDK" CLANG_MODULE_CACHE_PATH="$CACHE" \
- /usr/bin/swiftc \
+ "$SWIFTC" \
-sdk "$SDK" \
- -target arm64-apple-macosx14.0 \
+ -target "$ARCH-apple-macosx14.0" \
-module-cache-path "$CACHE" \
-parse-as-library \
"$ROOT/Sources/SpatialFolder/CanvasLayoutStore.swift" \
+ "$ROOT/Sources/SpatialFolder/CanvasSessionLock.swift" \
"$ROOT/Sources/SpatialFolder/CanvasViewport.swift" \
+ "$ROOT/Sources/SpatialFolder/CanvasLayoutEngine.swift" \
+ "$ROOT/Sources/SpatialFolder/FileIconCache.swift" \
+ "$ROOT/Sources/SpatialFolder/FileOperationCoordinator.swift" \
+ "$ROOT/Sources/SpatialFolder/FolderAccessRepository.swift" \
+ "$ROOT/Sources/SpatialFolder/FolderScanService.swift" \
+ "$ROOT/Sources/SpatialFolder/WindowAspectRatioController.swift" \
+ "$ROOT/Sources/SpatialFolder/OperationHistoryStore.swift" \
+ "$ROOT/Sources/SpatialFolder/OperationJournalStore.swift" \
+ "$ROOT/Sources/SpatialFolder/PreferencesMigrator.swift" \
+ "$ROOT/Sources/SpatialFolder/RecoveryAnalyzer.swift" \
"$ROOT/Sources/SpatialFolder/FolderCanvasModel.swift" \
"$ROOT/Tests/SelfTests/SelfTestMain.swift" \
-o "$OUTPUT"
diff --git a/scripts/run_standard_tests.zsh b/scripts/run_standard_tests.zsh
new file mode 100755
index 0000000..3fce459
--- /dev/null
+++ b/scripts/run_standard_tests.zsh
@@ -0,0 +1,13 @@
+#!/bin/zsh
+set -euo pipefail
+
+ROOT="${0:A:h:h}"
+SDK="$("$ROOT/scripts/select_macos_sdk.zsh")"
+CACHE="$ROOT/.build/standard-test-module-cache"
+
+mkdir -p "$CACHE"
+cd "$ROOT"
+SDKROOT="$SDK" CLANG_MODULE_CACHE_PATH="$CACHE" SWIFTPM_MODULECACHE_OVERRIDE="$CACHE" \
+ swift test \
+ --disable-sandbox \
+ -Xswiftc -warnings-as-errors
diff --git a/scripts/select_macos_sdk.zsh b/scripts/select_macos_sdk.zsh
new file mode 100755
index 0000000..f45a965
--- /dev/null
+++ b/scripts/select_macos_sdk.zsh
@@ -0,0 +1,17 @@
+#!/bin/zsh
+set -euo pipefail
+
+if [[ -n "${SPATIAL_FOLDER_SDK:-}" ]]; then
+ print -r -- "$SPATIAL_FOLDER_SDK"
+ exit 0
+fi
+
+DEVELOPER_ROOT="$(xcode-select -p)"
+FALLBACK="/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk"
+if [[ "$DEVELOPER_ROOT" == "/Library/Developer/CommandLineTools" && -d "$FALLBACK" ]]; then
+ # 当前机器的默认 26.5 SDK 来自 Swift 6.3.2,而编译器已更新到 6.3.3。
+ # 15.4 SDK 与当前编译器可以严格构建;安装完整 Xcode 后自动回到 Xcode 默认 SDK。
+ print -r -- "$FALLBACK"
+else
+ xcrun --sdk macosx --show-sdk-path
+fi