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