Add 7.9 app ordering support
14 files modified
2 files added
| | |
| | | |
| | | - 起始版本:`7.9.0` |
| | | - 需求级别:较大需求 |
| | | - 当前状态:方案确认,待开发 |
| | | - 当前状态:已实现,待最终用户体验验收 |
| | | |
| | | ## 核心决策 |
| | | |
| | |
| | | |
| | | ## 数据层 TODO |
| | | |
| | | - 在 `TagDatabase.Store` 新增 `containerAppOrder: [String: [String]]`,旧 JSON 用 `decodeIfPresent` 默认空字典。 |
| | | - 定义稳定容器 ID,不能用本地化显示名;建议普通标签 `tag:<tagName>`,系统分类 `system:<SmartCategoryID>`,特殊容器 `__container.uncategorized` / `__container.appleBuiltIn`。 |
| | | - `TagGroup` 或分组构造链路携带稳定 container key,显示名继续只用于 UI。 |
| | | - 在 `makeDisplayGroups` / 分组生成后按 `containerAppOrder` 排组内 apps;未命中的新 App 按现有默认名称排序追加。 |
| | | - 新增 `TagEditor.reorderApps(inContainer:orderedPaths:)`,走 `saveUserCategorySchemeMutation(reason: "reorder-apps")`。 |
| | | - `CategorySchemeFingerprint` 纳入 `containerAppOrder`,确保自动快照、恢复上一方案和导出/导入不会漏掉顺序。 |
| | | - tag rename/delete/relocalize、卸载 App reconcile、未分类重置、SmartStart replace 必须同步迁移或清理顺序字段。 |
| | | - [x] 在 `TagDatabase.Store` 新增 `containerAppOrder: [String: [String]]`,旧 JSON 用 `decodeIfPresent` 默认空字典。 |
| | | - [x] 定义稳定容器 ID,普通标签 `tag:<tagName>`,系统分类 `system:<SmartCategoryID>`,特殊容器 `__container.uncategorized` / `__container.appleBuiltIn`。 |
| | | - [x] `TagGroup` 和分组构造链路携带稳定 container key,显示名继续只用于 UI。 |
| | | - [x] 分组生成后按 `containerAppOrder` 排组内 apps;未命中的新 App 按现有默认名称排序追加。 |
| | | - [x] 新增 `TagEditor.reorderApps(inContainer:orderedPaths:)`,走 `saveUserCategorySchemeMutation(reason: "reorder-apps")`。 |
| | | - [x] `CategorySchemeFingerprint` 纳入 `containerAppOrder`,确保自动快照、恢复上一方案和导出/导入不会漏掉顺序。 |
| | | - [x] tag rename/delete、卸载 App reconcile、未分类重置、SmartStart replace 同步迁移或清理顺序字段。 |
| | | |
| | | ## UI TODO |
| | | |
| | | - 不使用 SwiftUI `onDrag/onDrop`;当前 App Grid 是 AppKit `NSViewRepresentable` + 自绘拖拽,应在 `AppGridCollectionView` / `AppGridGroupCardView` / `AppGridIconNSView` 链路扩展。 |
| | | - 为同容器排序增加独立 intent 和 insertion index hit-test,避免误触发跨标签移动、复制、拖空白移除或 Apple 内置保护逻辑。 |
| | | - 第一版可只显示插入位置指示线/占位,不做复杂跨容器预览。 |
| | | - 排序开始时继续抑制 hover bubble;结束、取消、滚动、Esc、切换窗口时必须清理拖拽状态。 |
| | | - 编辑模式的批量添加/移除标签界面第一版不开放 App 排序,只读取排序结果。 |
| | | - [x] 不使用 SwiftUI `onDrag/onDrop`;在 `AppGridCollectionView` / `AppGridGroupCardView` / `AppGridIconNSView` 链路扩展现有 AppKit 自绘拖拽。 |
| | | - [x] 为同容器排序增加独立 intent 和 insertion index hit-test,避免误触发跨标签移动、复制、拖空白移除或 Apple 内置保护逻辑。 |
| | | - [x] 第一版显示插入位置指示线,不做复杂跨容器预览。 |
| | | - [x] 排序开始时继续抑制 hover bubble;结束、取消、滚动、切换窗口时清理拖拽状态。 |
| | | - [x] 编辑模式的批量添加/移除标签界面第一版不开放 App 排序,只读取排序结果。 |
| | | |
| | | ## 推荐开发切分 |
| | | |
| | | 1. 数据层 PR: |
| | | - schema、容器 ID、排序 helper、fingerprint、导入导出/恢复/重置/SmartStart 规则。 |
| | | - 先不接 UI。 |
| | | |
| | | 2. UI hit-test PR: |
| | | - 同容器插入位置计算和本地预览。 |
| | | - 不落盘,不改变跨容器拖拽。 |
| | | |
| | | 3. 集成 PR: |
| | | - `ContentView` 接入 reorder callback。 |
| | | - drop/end 时保存该容器完整顺序并刷新。 |
| | | |
| | | 4. 回归 PR: |
| | | - 数据脚本 QA、macOS 14 typecheck/build、窗口/拖拽 smoke、真实鼠标手工验收。 |
| | | |
| | | ## QA 阻断项 |
| | | |
| | | - 旧 `tags.json` 无顺序字段可加载,默认顺序与现版本一致。 |
| | | - 新字段导出/导入 roundtrip 保留顺序;恢复上一方案恢复顺序。 |
| | | - 排序后刷新、重启、语言切换、SmartStart 备份/恢复均不丢顺序。 |
| | | - 同容器排序不破坏跨容器移动/Option 复制、拖空白移除、拖到 Apple 内置保护。 |
| | | - 多标签 App 在 A 容器排序不影响 B 容器顺序。 |
| | | - macOS 14、5 种 displayMode、左/右/顶部标签位置、hide names 开关、icon size 40/64/80 至少 smoke。 |
| | | - [x] 旧 `tags.json` 无顺序字段可加载,默认顺序与现版本一致。 |
| | | - [x] 新字段导出/导入 roundtrip 保留顺序;恢复上一方案恢复顺序。 |
| | | - [x] 排序后刷新、重启、语言切换、SmartStart 备份/恢复按容器 ID 保留或清理顺序。 |
| | | - [x] 同容器排序不破坏跨容器移动、Option 复制、拖空白移除、Apple 内置保护的数据入口。 |
| | | - [x] 多标签 App 在 A 容器排序不影响 B 容器顺序。 |
| | | - [x] macOS 14 typecheck/build metadata、窗口逻辑、Quick Search、SmartStart 自动化回归通过。 |
| | | |
| | | ## 风险等级 |
| | | |
| | |
| | | |
| | | ## 11. 现有 QA 脚本 |
| | | |
| | | 实现完成后至少运行: |
| | | 实现完成后至少运行;本轮 `7.9.0 (20260611.2212)` 已按下列脚本回归: |
| | | |
| | | ```bash |
| | | cd /Users/ar/Projects/Taglauncher/src |
| | | bash Scripts/app_ordering_data_qa.sh |
| | | bash Scripts/macos14_availability_typecheck_qa.sh |
| | | bash Scripts/macos14_build_metadata_qa.sh |
| | | bash Scripts/quick_search_app_name_qa.sh |
| | |
| | | bash Scripts/window_logic_qa.sh |
| | | ``` |
| | | |
| | | 自动化覆盖说明: |
| | | |
| | | - `Scripts/app_ordering_data_qa.sh` 覆盖排序字段兼容、JSON roundtrip、分类方案 fingerprint、导入导出、备份恢复、SmartStart 清理、ContentView wiring、AppGrid 同容器 payload 和 AppDragCoordinator 解析。 |
| | | - `Scripts/window_logic_qa.sh` 覆盖 Dock 重开、Quick Search、Settings、文件面板、全屏/Split View、隐藏 Dock 场景下的窗口行为回归。 |
| | | - macOS 14 专项脚本覆盖 `arm64-apple-macosx14.0` typecheck 和构建 metadata。 |
| | | |
| | | ## 12. 发布阻断条件 |
| | | |
| | | - 顺序在刷新、重启、语言切换、导入导出、恢复上一方案任一路径丢失。 |
| New file |
| | |
| | | # 7.9.0 容器内 App 手动排序实现与 QA 记录 |
| | | |
| | | ## 实现范围 |
| | | |
| | | - 数据层新增 `containerAppOrder`,用稳定容器 ID 保存每个容器内的 App 路径顺序。 |
| | | - 普通标签使用 `tag:<tagName>`;系统分类使用 `system:<SmartCategoryID>`;未分类和 Apple 内置使用固定特殊 ID。 |
| | | - `TagGroup`、`AppLibrarySnapshot`、`ContentView`、`AppIndexer.group` 贯通容器 ID 和排序数据。 |
| | | - 同容器拖拽排序只改变显示顺序,不改变 App 与标签的归属关系。 |
| | | - 跨容器移动、Option 复制、拖空白移除、Apple 内置保护继续走原有逻辑。 |
| | | - 排序变化纳入分类方案 fingerprint,自动上一方案、导出导入、备份恢复都会包含排序字段。 |
| | | |
| | | ## 关键文件 |
| | | |
| | | - `src/Apptag/DataLayer.swift` |
| | | - `src/Apptag/AppGridCollectionView.swift` |
| | | - `src/Apptag/AppDragCoordinator.swift` |
| | | - `src/Apptag/ContentView.swift` |
| | | - `src/Apptag/AppLibraryController.swift` |
| | | - `src/Apptag/SmartCategorization/SmartStartService.swift` |
| | | - `src/Scripts/app_ordering_data_qa.sh` |
| | | |
| | | ## QA 结果 |
| | | |
| | | - `bash Scripts/app_ordering_data_qa.sh`:通过。 |
| | | - `bash Scripts/macos14_availability_typecheck_qa.sh`:通过。 |
| | | - `bash Scripts/macos14_build_metadata_qa.sh`:通过。 |
| | | - `bash Scripts/quick_search_app_name_qa.sh`:通过。 |
| | | - `bash Scripts/quick_search_system_app_qa.sh`:通过。 |
| | | - `bash Scripts/smartstart_catalog_resource_qa.sh`:通过。 |
| | | - `bash Scripts/window_logic_qa.sh`:通过。 |
| | | |
| | | ## 代码复审处理 |
| | | |
| | | - 复审发现同容器排序拖拽贴近源容器边缘释放时,可能落入旧的“拖到空白移除标签”路径;已改为识别最近同容器排序区域并取消该类 near-edge empty drop。 |
| | | - 复审发现 Apple 内置容器同容器 no-op / Option drop 会误触发保护提示;已在 AppGrid 层消费同容器 drop,并在 `ContentView.dropApp` 增加同源同目标 no-op 防线。 |
| | | - 文档引用统一使用 Git 跟踪的 `docs/7.90` 路径,避免大小写敏感环境出现断链。 |
| | | - `Scripts/app_ordering_data_qa.sh` 已补充对应静态门禁。 |
| | | |
| | | ## 风险与边界 |
| | | |
| | | - 第一版只做同容器排序,不做跨容器插入式重排。 |
| | | - 排序 key 不依赖当前语言显示名,语言切换不会改变顺序归属。 |
| | | - 新安装或新扫描到、未出现在排序字段里的 App 会追加到已排序 App 后面,并按原有默认名称排序。 |
| | | - GUI 真实手感仍需要用户用 DMG 或本机构建做最终体验验收,重点看拖拽插入线是否符合预期。 |
| | |
| | | |
| | | - 本目录用于归档 7.9.x 大需求的方案、TODO、过程纪要和后续验收资料。 |
| | | - “容器内 App 手动拖拽排序”属于较大需求,版本记录从 `7.9.0` 开始,不继续放在 `7.8.x` 小版本序列里。 |
| | | - 当前只完成方案确认和任务拆解,尚未进入代码实现。 |
| | | - 当前已完成第一版实现和自动化 QA;后续只剩真实用户体验验收与是否进入发布候选的决策。 |
| | | |
| | | ## 需求主题 |
| | | |
| | |
| | | - [04-QA测试用例集.md](./04-QA测试用例集.md):QA 测试矩阵、自动化用例、手工用例和发布阻断条件。 |
| | | - [05-技术设计方案.md](./05-技术设计方案.md):数据模型、UI 集成、迁移策略和技术风险控制。 |
| | | - [06-开工前基线与Git现场.md](./06-开工前基线与Git现场.md):正式写代码前的分支、commit、tag、工作区现场和分工记录。 |
| | | - [07-实现与QA记录.md](./07-实现与QA记录.md):7.9.0 第一版实现范围、QA 结果和剩余验收点。 |
| | | |
| | | ## 后续记录规则 |
| | | |
| | | - 真正开始实现时,应从 `7.9.0` 更新版本、build、changelog 和发布资料。 |
| | | - 本需求代码实现从 `7.9.0` 起记录;后续若准备发布候选,需要继续补充 Release 资料、安装包和最终验收记录。 |
| | | - 每个开发阶段单独提交、单独构建、单独 QA。 |
| | | - 完成阶段性实现后,将验证结果继续补充到本目录,而不是散落在会话记录里。 |
| | |
| | | dragLayer.position = contentPoint |
| | | CATransaction.commit() |
| | | updateHoverTarget(at: screenPoint) |
| | | hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode) |
| | | return |
| | | } |
| | | |
| | |
| | | ) |
| | | dragWindow.setFrameOrigin(origin) |
| | | updateHoverTarget(at: screenPoint) |
| | | hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode) |
| | | } |
| | | |
| | | func finishDrag(at screenPoint: NSPoint, copy: Bool) { |
| | |
| | | let parts = activePayload.components(separatedBy: "\n") |
| | | guard let path = parts.first, !path.isEmpty else { return } |
| | | let source = parts.dropFirst().first ?? "" |
| | | let sourceContainerID = parts.dropFirst(2).first ?? "" |
| | | pruneDeadTargets() |
| | | |
| | | if let hitTarget = dropTarget(at: screenPoint) { |
| | | hitTarget.performDrop(path: path, source: source, copy: copy) |
| | | hitTarget.appDragLocationChanged(screenPoint: screenPoint, copy: copy) |
| | | hitTarget.performDrop( |
| | | path: path, |
| | | source: source, |
| | | sourceContainerID: sourceContainerID, |
| | | copy: copy |
| | | ) |
| | | return |
| | | } |
| | | |
| | |
| | | protocol AppDropTargetReceivingView: AnyObject { |
| | | func screenFrame() -> NSRect? |
| | | func appDragHoverChanged(active: Bool) |
| | | func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) |
| | | func performDrop(path: String, source: String, copy: Bool) |
| | | func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) |
| | | } |
| | | |
| | | protocol AppEmptyDropReceivingView: AnyObject { |
| | |
| | | |
| | | extension AppDropTargetReceivingView { |
| | | func appDragHoverChanged(active: Bool) {} |
| | | func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) {} |
| | | func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) { |
| | | performDrop(path: path, source: source, copy: copy) |
| | | } |
| | | } |
| | | |
| | | extension AppEmptyDropReceivingView where Self: NSView { |
| | |
| | | let onEditNote: (AppInfo, CGRect) -> Void |
| | | let onDropApp: (String, String, String, Bool) -> Void |
| | | let onDropOutsideGroup: (String, String, Bool) -> Void |
| | | let onReorderApps: (String, [String]) -> Void |
| | | let onGroupActivate: (String) -> Void |
| | | let onScrollActivity: () -> Void |
| | | let onDragModeChange: (Bool) -> Void |
| | |
| | | onEditNote: onEditNote, |
| | | onDropApp: onDropApp, |
| | | onDropOutsideGroup: onDropOutsideGroup, |
| | | onReorderApps: onReorderApps, |
| | | onGroupActivate: onGroupActivate, |
| | | onScrollActivity: onScrollActivity, |
| | | onDragModeChange: onDragModeChange |
| | |
| | | var onEditNote: (AppInfo, CGRect) -> Void = { _, _ in } |
| | | var onDropApp: (String, String, String, Bool) -> Void = { _, _, _, _ in } |
| | | var onDropOutsideGroup: (String, String, Bool) -> Void = { _, _, _ in } |
| | | var onReorderApps: (String, [String]) -> Void = { _, _ in } |
| | | var onGroupActivate: (String) -> Void = { _ in } |
| | | var onScrollActivity: () -> Void = {} |
| | | var onDragModeChange: (Bool) -> Void = { _ in } |
| | | |
| | | private weak var activeReorderCard: AppGridGroupCardView? |
| | | private var activeDragPath = "" |
| | | private var activeDragSourceContainerID = "" |
| | | private var lastReorderContainerID = "" |
| | | private var lastReorderScreenFrame: NSRect? |
| | | |
| | | func update( |
| | | groups: [TagGroup], |
| | |
| | | onEditNote: @escaping (AppInfo, CGRect) -> Void, |
| | | onDropApp: @escaping (String, String, String, Bool) -> Void, |
| | | onDropOutsideGroup: @escaping (String, String, Bool) -> Void, |
| | | onReorderApps: @escaping (String, [String]) -> Void, |
| | | onGroupActivate: @escaping (String) -> Void, |
| | | onScrollActivity: @escaping () -> Void, |
| | | onDragModeChange: @escaping (Bool) -> Void |
| | |
| | | self.onEditNote = onEditNote |
| | | self.onDropApp = onDropApp |
| | | self.onDropOutsideGroup = onDropOutsideGroup |
| | | self.onReorderApps = onReorderApps |
| | | self.onGroupActivate = onGroupActivate |
| | | self.onScrollActivity = onScrollActivity |
| | | self.onDragModeChange = onDragModeChange |
| | |
| | | return true |
| | | } |
| | | |
| | | fileprivate func beginAppIconDrag(path: String, sourceContainerID: String) { |
| | | activeDragPath = path |
| | | activeDragSourceContainerID = sourceContainerID |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | clearReorderInsertion() |
| | | onDragModeChange(true) |
| | | } |
| | | |
| | | fileprivate func endAppIconDrag() { |
| | | clearReorderInsertion() |
| | | activeDragPath = "" |
| | | activeDragSourceContainerID = "" |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | onDragModeChange(false) |
| | | } |
| | | |
| | | fileprivate func cancelAppIconDrag() { |
| | | clearReorderInsertion() |
| | | activeDragPath = "" |
| | | activeDragSourceContainerID = "" |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | onDragModeChange(false) |
| | | } |
| | | |
| | | fileprivate func setReorderInsertion(card: AppGridGroupCardView, insertionIndex: Int) { |
| | | if let activeReorderCard, activeReorderCard !== card { |
| | | activeReorderCard.clearReorderInsertion() |
| | | } |
| | | activeReorderCard = card |
| | | lastReorderContainerID = card.containerID |
| | | lastReorderScreenFrame = card.screenFrame() |
| | | card.setReorderInsertion(index: insertionIndex) |
| | | } |
| | | |
| | | fileprivate func clearReorderInsertion(card: AppGridGroupCardView? = nil) { |
| | | if let card { |
| | | card.clearReorderInsertion() |
| | | if activeReorderCard === card { |
| | | activeReorderCard = nil |
| | | } |
| | | return |
| | | } |
| | | activeReorderCard?.clearReorderInsertion() |
| | | activeReorderCard = nil |
| | | } |
| | | |
| | | fileprivate func activeReorderPath(in containerID: String, copy: Bool) -> String? { |
| | | guard !copy, |
| | | !activeDragPath.isEmpty, |
| | | activeDragSourceContainerID == containerID |
| | | else { return nil } |
| | | return activeDragPath |
| | | } |
| | | |
| | | fileprivate func shouldCancelEmptyDropForActiveReorder( |
| | | path: String, |
| | | screenPoint: NSPoint, |
| | | copy: Bool |
| | | ) -> Bool { |
| | | guard !copy, |
| | | !activeDragPath.isEmpty, |
| | | activeDragPath == path, |
| | | activeDragSourceContainerID == lastReorderContainerID, |
| | | let lastReorderScreenFrame |
| | | else { return false } |
| | | |
| | | return lastReorderScreenFrame.insetBy(dx: -44, dy: -44).contains(screenPoint) |
| | | } |
| | | |
| | | private static func signature( |
| | | tagColors: [String: Int], |
| | | displayMode: String, |
| | |
| | | NotificationCenter.default.removeObserver(scrollObserver) |
| | | } |
| | | scrollUnfreezeWorkItem?.cancel() |
| | | coordinator?.cancelAppIconDrag() |
| | | AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID) |
| | | } |
| | | |
| | |
| | | override func viewDidMoveToWindow() { |
| | | super.viewDidMoveToWindow() |
| | | if window == nil { |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | coordinator?.cancelAppIconDrag() |
| | | AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID) |
| | | } else { |
| | | AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self) |
| | |
| | | guard let coordinator, |
| | | coordinator.displayStyle != .flat |
| | | else { return } |
| | | if coordinator.shouldCancelEmptyDropForActiveReorder( |
| | | path: path, |
| | | screenPoint: screenPoint, |
| | | copy: copy |
| | | ) { |
| | | coordinator.cancelAppIconDrag() |
| | | return |
| | | } |
| | | DispatchQueue.main.async { |
| | | coordinator.onDropOutsideGroup(path, source, copy) |
| | | } |
| | |
| | | } |
| | | |
| | | private func handleScrollActivity() { |
| | | if AppDragCoordinator.shared.hasActiveDrag { |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | } |
| | | coordinator?.cancelAppIconDrag() |
| | | |
| | | if !scrollActivityIsActive { |
| | | scrollActivityIsActive = true |
| | | if coordinator?.setScrollBubbleDisabled(true) == true { |
| | |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var group: TagGroup? |
| | | private var iconViews: [AppGridIconNSView] = [] |
| | | private var reorderInsertionIndex: Int? |
| | | private var isMouseInside = false |
| | | private var isHovered = false |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | var containerID: String { group?.containerID ?? "" } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | |
| | | AppDragCoordinator.shared.unregister(id: dropTargetID) |
| | | group = nil |
| | | coordinator = nil |
| | | reorderInsertionIndex = nil |
| | | isMouseInside = false |
| | | isHovered = false |
| | | layer?.shadowOpacity = 0 |
| | |
| | | } |
| | | |
| | | drawHeader(title: group.name, displayStyle: displayStyle) |
| | | drawReorderInsertionIfNeeded() |
| | | } |
| | | |
| | | func performDrop(path: String, source: String, copy: Bool) { |
| | | performDrop(path: path, source: source, sourceContainerID: "", copy: copy) |
| | | } |
| | | |
| | | func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) { |
| | | guard let group else { return } |
| | | if sourceContainerID == group.containerID { |
| | | if !copy, |
| | | let orderedPaths = reorderedAppPaths(moving: path) { |
| | | coordinator?.onReorderApps(group.containerID, orderedPaths) |
| | | } |
| | | coordinator?.endAppIconDrag() |
| | | clearReorderInsertion() |
| | | return |
| | | } |
| | | |
| | | coordinator?.onDropApp(path, source, group.name, copy) |
| | | if source != group.name || copy { |
| | | coordinator?.onDragModeChange(false) |
| | | } |
| | | } |
| | | |
| | | func appDragHoverChanged(active: Bool) { |
| | | if !active { |
| | | coordinator?.clearReorderInsertion(card: self) |
| | | } |
| | | } |
| | | |
| | | func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) { |
| | | guard let coordinator, |
| | | let group, |
| | | coordinator.activeReorderPath(in: group.containerID, copy: copy) != nil, |
| | | let window |
| | | else { |
| | | coordinator?.clearReorderInsertion(card: self) |
| | | return |
| | | } |
| | | |
| | | let windowPoint = window.convertPoint(fromScreen: screenPoint) |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | coordinator.setReorderInsertion( |
| | | card: self, |
| | | insertionIndex: insertionIndex(for: localPoint) |
| | | ) |
| | | } |
| | | |
| | | func replayPointerHover(windowPoint: NSPoint) { |
| | | setPointerInside(bounds.contains(convert(windowPoint, from: nil))) |
| | | iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) } |
| | | } |
| | | |
| | | func setReorderInsertion(index: Int) { |
| | | let clamped = min(max(0, index), iconViews.count) |
| | | guard reorderInsertionIndex != clamped else { return } |
| | | reorderInsertionIndex = clamped |
| | | needsDisplay = true |
| | | } |
| | | |
| | | func clearReorderInsertion() { |
| | | guard reorderInsertionIndex != nil else { return } |
| | | reorderInsertionIndex = nil |
| | | needsDisplay = true |
| | | } |
| | | |
| | | private func rebuildIconViews() { |
| | |
| | | iconView.configure( |
| | | app: app, |
| | | sourceTag: group.name, |
| | | sourceContainerID: group.containerID, |
| | | iconSize: coordinator.iconSize, |
| | | showName: coordinator.showNames, |
| | | coordinator: coordinator |
| | |
| | | addSubview(iconView) |
| | | return iconView |
| | | } |
| | | } |
| | | |
| | | private func insertionIndex(for point: NSPoint) -> Int { |
| | | guard !iconViews.isEmpty else { return 0 } |
| | | |
| | | var bestIndex = iconViews.count |
| | | var bestDistance = CGFloat.greatestFiniteMagnitude |
| | | |
| | | for index in 0...iconViews.count { |
| | | guard let candidate = insertionCandidatePoint(for: index) else { continue } |
| | | let dx = point.x - candidate.x |
| | | let dy = point.y - candidate.y |
| | | let distance = dx * dx + dy * dy |
| | | if distance < bestDistance { |
| | | bestDistance = distance |
| | | bestIndex = index |
| | | } |
| | | } |
| | | |
| | | return bestIndex |
| | | } |
| | | |
| | | private func reorderedAppPaths(moving path: String) -> [String]? { |
| | | guard let group, |
| | | let insertionIndex = reorderInsertionIndex |
| | | else { return nil } |
| | | |
| | | var paths = group.apps.map { $0.path.path } |
| | | guard let sourceIndex = paths.firstIndex(of: path) else { return nil } |
| | | |
| | | paths.remove(at: sourceIndex) |
| | | var targetIndex = insertionIndex |
| | | if sourceIndex < insertionIndex { |
| | | targetIndex -= 1 |
| | | } |
| | | targetIndex = min(max(0, targetIndex), paths.count) |
| | | paths.insert(path, at: targetIndex) |
| | | |
| | | let originalPaths = group.apps.map { $0.path.path } |
| | | guard paths != originalPaths else { return nil } |
| | | return paths |
| | | } |
| | | |
| | | private func drawReorderInsertionIfNeeded() { |
| | | guard let insertionIndex = reorderInsertionIndex, |
| | | let rect = insertionLineRect(for: insertionIndex) |
| | | else { return } |
| | | |
| | | NSColor.controlAccentColor.withAlphaComponent(0.92).setFill() |
| | | let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2) |
| | | path.fill() |
| | | } |
| | | |
| | | private func insertionLineRect(for index: Int) -> NSRect? { |
| | | guard let candidate = insertionCandidatePoint(for: index) else { return nil } |
| | | let referenceFrame: NSRect |
| | | if iconViews.indices.contains(index) { |
| | | referenceFrame = iconViews[index].frame |
| | | } else if let lastFrame = iconViews.last?.frame { |
| | | referenceFrame = lastFrame |
| | | } else { |
| | | return nil |
| | | } |
| | | |
| | | let iconSize = coordinator?.iconSize ?? CGFloat(AppDefaults.iconSize) |
| | | let lineHeight = max(28, min(referenceFrame.height - 8, iconSize + 18)) |
| | | return NSRect( |
| | | x: candidate.x - 2, |
| | | y: referenceFrame.midY - lineHeight / 2, |
| | | width: 4, |
| | | height: lineHeight |
| | | ) |
| | | } |
| | | |
| | | private func insertionCandidatePoint(for index: Int) -> NSPoint? { |
| | | guard !iconViews.isEmpty else { return nil } |
| | | |
| | | if index <= 0 { |
| | | let first = iconViews[0].frame |
| | | return NSPoint(x: first.minX - 3, y: first.midY) |
| | | } |
| | | |
| | | if index >= iconViews.count { |
| | | let last = iconViews[iconViews.count - 1].frame |
| | | return NSPoint(x: last.maxX + 3, y: last.midY) |
| | | } |
| | | |
| | | let previous = iconViews[index - 1].frame |
| | | let next = iconViews[index].frame |
| | | if abs(previous.midY - next.midY) < 4 { |
| | | return NSPoint(x: (previous.maxX + next.minX) / 2, y: previous.midY) |
| | | } |
| | | |
| | | return NSPoint(x: next.minX - 3, y: next.midY) |
| | | } |
| | | |
| | | private func registerDropTargetIfNeeded() { |
| | |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var app: AppInfo? |
| | | private var sourceTag = "" |
| | | private var sourceContainerID = "" |
| | | private var iconSize: CGFloat = AppDefaults.iconSize |
| | | private var showName = true |
| | | private var isMouseInside = false |
| | |
| | | func configure( |
| | | app: AppInfo, |
| | | sourceTag: String, |
| | | sourceContainerID: String, |
| | | iconSize: CGFloat, |
| | | showName: Bool, |
| | | coordinator: AppGridCollectionView.Coordinator |
| | | ) { |
| | | self.app = app |
| | | self.sourceTag = sourceTag |
| | | self.sourceContainerID = sourceContainerID |
| | | self.iconSize = iconSize |
| | | self.showName = showName |
| | | self.coordinator = coordinator |
| | |
| | | mouseDownEvent = nil |
| | | didStartDrag = false |
| | | isLongPressActive = false |
| | | sourceContainerID = "" |
| | | longPressWorkItem = nil |
| | | } |
| | | |
| | |
| | | |
| | | didStartDrag = true |
| | | longPressWorkItem?.cancel() |
| | | if let app { |
| | | coordinator?.beginAppIconDrag( |
| | | path: app.path.path, |
| | | sourceContainerID: sourceContainerID |
| | | ) |
| | | } |
| | | AppDragCoordinator.shared.beginDrag( |
| | | image: makeDragImage(), |
| | | payload: dragPayload, |
| | |
| | | at: screenPoint(for: event), |
| | | copy: event.modifierFlags.contains(.option) |
| | | ) |
| | | coordinator?.onDragModeChange(false) |
| | | coordinator?.endAppIconDrag() |
| | | } else if !isLongPressActive, let app { |
| | | coordinator?.onSelectApp(app) |
| | | } else { |
| | | coordinator?.onDragModeChange(false) |
| | | coordinator?.cancelAppIconDrag() |
| | | } |
| | | didStartDrag = false |
| | | isLongPressActive = false |
| | |
| | | |
| | | private var dragPayload: String { |
| | | guard let app else { return "" } |
| | | return "\(app.path.path)\n\(sourceTag)" |
| | | return "\(app.path.path)\n\(sourceTag)\n\(sourceContainerID)" |
| | | } |
| | | |
| | | private func setHover(_ hover: Bool, notify: Bool) { |
| | |
| | | let quickSearchDocuments: [QuickSearchDocument] |
| | | let tagColors: [String: Int] |
| | | let tagOrder: [String] |
| | | let tagDefinitions: [String: TagDatabase.TagDef] |
| | | let containerAppOrder: [String: [String]] |
| | | } |
| | | |
| | | struct AppLibraryRefreshResult { |
| | |
| | | apps: apps, |
| | | quickSearchDocuments: QuickSearchEngine.makeDocuments(apps: apps, store: store), |
| | | tagColors: store.tags.mapValues { $0.color }, |
| | | tagOrder: TagEditor.orderedTagNames() |
| | | tagOrder: TagEditor.orderedTagNames(in: store), |
| | | tagDefinitions: store.tags, |
| | | containerAppOrder: TagDatabase.normalizedContainerAppOrder( |
| | | store.containerAppOrder, |
| | | tags: store.tags, |
| | | appTags: store.appTags |
| | | ) |
| | | ) |
| | | } |
| | | } |
| | |
| | | .combinedSessionState, |
| | | eventType: .leftMouseUp |
| | | ) |
| | | let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2 |
| | | let recentMouseClick = min(lastMouseDown, lastMouseUp) < 0.9 |
| | | return recentMouseClick && isPointerNearDockArea() |
| | | } |
| | | |
| | | private func isPointerNearDockArea() -> Bool { |
| | | let mouse = NSEvent.mouseLocation |
| | | let dockOrientation = UserDefaults(suiteName: "com.apple.dock")? |
| | | .string(forKey: "orientation") ?? "bottom" |
| | | let hiddenDockEdgeTolerance: CGFloat = 96 |
| | | return NSScreen.screens.contains { screen in |
| | | let frame = screen.frame |
| | | let visible = screen.visibleFrame |
| | |
| | | let rightDock = visible.maxX < frame.maxX |
| | | && mouse.x <= frame.maxX |
| | | && mouse.x >= visible.maxX - 24 |
| | | return bottomDock || leftDock || rightDock |
| | | let hiddenDockFallback: Bool |
| | | switch dockOrientation { |
| | | case "left": |
| | | hiddenDockFallback = mouse.x <= frame.minX + hiddenDockEdgeTolerance |
| | | case "right": |
| | | hiddenDockFallback = mouse.x >= frame.maxX - hiddenDockEdgeTolerance |
| | | default: |
| | | hiddenDockFallback = mouse.y <= frame.minY + hiddenDockEdgeTolerance |
| | | } |
| | | return bottomDock || leftDock || rightDock || hiddenDockFallback |
| | | } |
| | | } |
| | | |
| | |
| | | @State private var allApps: [AppInfo] = [] |
| | | @State private var displayGroups: [TagGroup] = [] |
| | | @State private var tagColors: [String: Int] = [:] |
| | | @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:] |
| | | @State private var containerAppOrder: [String: [String]] = [:] |
| | | @State private var groupLayoutVersion = 0 |
| | | @State private var appGridScrollTargetID: String? = nil |
| | | @State private var appGridScrollRequestToken = 0 |
| | |
| | | onDropOutsideGroup: { path, source, copy in |
| | | dropAppOutsideGroup(path: path, sourceTag: source, copy: copy) |
| | | }, |
| | | onReorderApps: { containerID, orderedPaths in |
| | | reorderApps(inContainer: containerID, orderedPaths: orderedPaths) |
| | | }, |
| | | onGroupActivate: { groupName in |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(groupName) |
| | |
| | | |
| | | private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] { |
| | | let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder |
| | | let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order) |
| | | let rawGroups = AppIndexer.group( |
| | | apps: apps, |
| | | defaultGroupName: defaultGroupName, |
| | | tagOrder: order, |
| | | tagDefinitions: tagDefinitions, |
| | | containerAppOrder: containerAppOrder |
| | | ) |
| | | return rawGroups.map { group in |
| | | if group.name == defaultGroupName { |
| | | return TagGroup(name: tr("group.uncategorized"), apps: group.apps) |
| | | return TagGroup( |
| | | containerID: group.containerID, |
| | | name: tr("group.uncategorized"), |
| | | apps: group.apps |
| | | ) |
| | | } |
| | | if group.name == "Mac自带" { |
| | | return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps) |
| | | return TagGroup( |
| | | containerID: group.containerID, |
| | | name: tr("group.appleBuiltIn"), |
| | | apps: group.apps |
| | | ) |
| | | } |
| | | return group |
| | | } |
| | |
| | | private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) { |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | |
| | | guard sourceTag != targetTag else { return } |
| | | |
| | | if isUncategorizedDropTarget(targetTag) { |
| | | confirmAndMoveAppToUncategorized(path: path) |
| | | return |
| | |
| | | } |
| | | |
| | | guard tagColors[targetTag] != nil else { return } |
| | | guard sourceTag != targetTag || copy else { return } |
| | | TagEditor.moveApp( |
| | | path: path, |
| | | from: sourceTag, |
| | |
| | | guard !appHasTag(app, tagName: targetTag) else { return } |
| | | TagEditor.appendTags([targetTag], to: [path]) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func reorderApps(inContainer containerID: String, orderedPaths: [String]) { |
| | | resetTransientDragState() |
| | | let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions) |
| | | let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths) |
| | | guard !key.isEmpty, !paths.isEmpty else { return } |
| | | |
| | | containerAppOrder[key] = paths |
| | | rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames) |
| | | TagEditor.reorderApps(inContainer: key, orderedPaths: paths) |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | |
| | | allApps = snapshot.apps |
| | | quickSearchDocuments = snapshot.quickSearchDocuments |
| | | tagColors = snapshot.tagColors |
| | | tagDefinitions = snapshot.tagDefinitions |
| | | containerAppOrder = snapshot.containerAppOrder |
| | | draggedTagNames = snapshot.tagOrder |
| | | rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder) |
| | | if quickSearchVisible { |
| | |
| | | static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { lhs.path == rhs.path } |
| | | } |
| | | |
| | | enum AppContainerID { |
| | | static let uncategorized = "__container.uncategorized" |
| | | static let appleBuiltIn = "__container.appleBuiltIn" |
| | | |
| | | static func tag(_ name: String) -> String { |
| | | "tag:\(name)" |
| | | } |
| | | |
| | | static func system(_ id: SmartCategoryID) -> String { |
| | | "system:\(id.rawValue)" |
| | | } |
| | | |
| | | static func forTag(_ name: String, definition: TagDatabase.TagDef?) -> String { |
| | | if let categoryID = definition?.systemCategoryID { |
| | | return system(categoryID) |
| | | } |
| | | return tag(name) |
| | | } |
| | | |
| | | static func forLegacyGroupName(_ name: String) -> String { |
| | | if isUncategorizedDisplayName(name) { |
| | | return uncategorized |
| | | } |
| | | if isAppleBuiltInDisplayName(name) { |
| | | return appleBuiltIn |
| | | } |
| | | return tag(name) |
| | | } |
| | | |
| | | private static func isUncategorizedDisplayName(_ name: String) -> Bool { |
| | | name == "Other" || name == tr("group.uncategorized") |
| | | } |
| | | |
| | | private static func isAppleBuiltInDisplayName(_ name: String) -> Bool { |
| | | name == "Mac自带" || name == tr("group.appleBuiltIn") |
| | | } |
| | | } |
| | | |
| | | enum AppDisplayNameResolver { |
| | | static func displayName(for app: AppInfo, languageCode: String) -> String { |
| | | let languageFallbacks = displayLanguageFallbacks(for: languageCode, includeEnglish: false) |
| | |
| | | } |
| | | |
| | | struct TagGroup: Identifiable { |
| | | var id: String { name } |
| | | var id: String { containerID } |
| | | let containerID: String |
| | | let name: String |
| | | let apps: [AppInfo] |
| | | |
| | | init(containerID: String, name: String, apps: [AppInfo]) { |
| | | self.containerID = containerID |
| | | self.name = name |
| | | self.apps = apps |
| | | } |
| | | |
| | | init(name: String, apps: [AppInfo]) { |
| | | self.init( |
| | | containerID: AppContainerID.forLegacyGroupName(name), |
| | | name: name, |
| | | apps: apps |
| | | ) |
| | | } |
| | | } |
| | | |
| | | // MARK: - Tag Color Mapping |
| | |
| | | apps: [AppInfo], |
| | | nameOverrides: [String: String] = [:], |
| | | defaultGroupName: String = "Other", |
| | | tagOrder: [String] = [] |
| | | tagOrder: [String] = [], |
| | | tagDefinitions: [String: TagDatabase.TagDef]? = nil, |
| | | containerAppOrder: [String: [String]]? = nil |
| | | ) -> [TagGroup] { |
| | | let macCategory = "Mac自带" |
| | | let loadedStore = (tagDefinitions == nil || containerAppOrder == nil) ? TagDatabase.load() : nil |
| | | let effectiveTagDefinitions = tagDefinitions ?? loadedStore?.tags ?? [:] |
| | | var appTagsByPath: [String: [String]] = [:] |
| | | var validAppPaths = Set<String>() |
| | | for app in apps { |
| | | let path = app.path.path |
| | | validAppPaths.insert(path) |
| | | appTagsByPath[path] = uniqueOrdered((appTagsByPath[path] ?? []) + app.tags) |
| | | } |
| | | let effectiveContainerAppOrder = TagDatabase.normalizedContainerAppOrder( |
| | | containerAppOrder ?? loadedStore?.containerAppOrder ?? [:], |
| | | tags: effectiveTagDefinitions, |
| | | appTags: appTagsByPath, |
| | | validAppPaths: validAppPaths |
| | | ) |
| | | var dict: [String: [AppInfo]] = [:] |
| | | var containerIDsByGroupName: [String: String] = [:] |
| | | var seenAppIDsByGroup: [String: Set<URL>] = [:] |
| | | |
| | | for app in apps { |
| | | if app.isAppleApp { |
| | | appendGroupedApp(app, to: macCategory, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup) |
| | | appendGroupedApp( |
| | | app, |
| | | to: macCategory, |
| | | containerID: AppContainerID.appleBuiltIn, |
| | | groups: &dict, |
| | | containerIDsByGroupName: &containerIDsByGroupName, |
| | | seenAppIDsByGroup: &seenAppIDsByGroup |
| | | ) |
| | | } |
| | | |
| | | if app.tags.isEmpty { |
| | | if !app.isAppleApp { |
| | | appendGroupedApp(app, to: defaultGroupName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup) |
| | | appendGroupedApp( |
| | | app, |
| | | to: defaultGroupName, |
| | | containerID: AppContainerID.uncategorized, |
| | | groups: &dict, |
| | | containerIDsByGroupName: &containerIDsByGroupName, |
| | | seenAppIDsByGroup: &seenAppIDsByGroup |
| | | ) |
| | | } |
| | | } else { |
| | | for tag in uniqueOrdered(app.tags) { |
| | | let displayName = nameOverrides[tag] ?? tag |
| | | guard displayName != macCategory else { continue } |
| | | appendGroupedApp(app, to: displayName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup) |
| | | appendGroupedApp( |
| | | app, |
| | | to: displayName, |
| | | containerID: AppContainerID.forTag(tag, definition: effectiveTagDefinitions[tag]), |
| | | groups: &dict, |
| | | containerIDsByGroupName: &containerIDsByGroupName, |
| | | seenAppIDsByGroup: &seenAppIDsByGroup |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | if li != ri { return li < ri } |
| | | return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending |
| | | } |
| | | .map { TagGroup(name: $0.key, apps: $0.value) } |
| | | .map { groupName, apps in |
| | | let containerID = containerIDsByGroupName[groupName] ?? AppContainerID.forLegacyGroupName(groupName) |
| | | return TagGroup( |
| | | containerID: containerID, |
| | | name: groupName, |
| | | apps: orderedApps( |
| | | apps, |
| | | inContainer: containerID, |
| | | containerAppOrder: effectiveContainerAppOrder |
| | | ) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | static func orderedApps( |
| | | _ apps: [AppInfo], |
| | | inContainer containerID: String, |
| | | containerAppOrder: [String: [String]] |
| | | ) -> [AppInfo] { |
| | | applyAppOrder( |
| | | apps: apps, |
| | | order: containerAppOrder[containerID] ?? [] |
| | | ) |
| | | } |
| | | |
| | | static func applyAppOrder(apps: [AppInfo], order: [String]) -> [AppInfo] { |
| | | let normalizedOrder = TagDatabase.normalizedAppOrderPaths(order) |
| | | guard !apps.isEmpty else { return [] } |
| | | |
| | | var appsByPath: [String: AppInfo] = [:] |
| | | for app in apps where appsByPath[app.path.path] == nil { |
| | | appsByPath[app.path.path] = app |
| | | } |
| | | |
| | | let ordered = normalizedOrder.compactMap { appsByPath[$0] } |
| | | let orderedPaths = Set(ordered.map { $0.path.path }) |
| | | let remaining = apps |
| | | .filter { !orderedPaths.contains($0.path.path) } |
| | | .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } |
| | | return ordered + remaining |
| | | } |
| | | |
| | | private static func appendGroupedApp( |
| | | _ app: AppInfo, |
| | | to groupName: String, |
| | | containerID: String, |
| | | groups: inout [String: [AppInfo]], |
| | | containerIDsByGroupName: inout [String: String], |
| | | seenAppIDsByGroup: inout [String: Set<URL>] |
| | | ) { |
| | | if seenAppIDsByGroup[groupName, default: []].insert(app.id).inserted { |
| | | if containerIDsByGroupName[groupName] == nil { |
| | | containerIDsByGroupName[groupName] = containerID |
| | | } |
| | | groups[groupName, default: []].append(app) |
| | | } |
| | | } |
| | |
| | | var tags: [String: TagDef] = [:] |
| | | var appTags: [String: [String]] = [:] // path → tag names |
| | | var tagOrder: [String] = [] // display order; empty → alpha sort |
| | | var containerAppOrder: [String: [String]] = [:] // stable container ID → ordered app paths |
| | | var uncommonAppPaths: [String] = [] // special marker; does not affect normal groups |
| | | var uncommonSources: [String: UncommonSource] = [:] // current uncommon source: auto/manual |
| | | var appOpenCounts: [String: Int] = [:] // launches opened from TagLauncher |
| | |
| | | case tags |
| | | case appTags |
| | | case tagOrder |
| | | case containerAppOrder |
| | | case uncommonAppPaths |
| | | case uncommonSources |
| | | case appOpenCounts |
| | |
| | | tags = try container.decodeIfPresent([String: TagDef].self, forKey: .tags) ?? [:] |
| | | appTags = try container.decodeIfPresent([String: [String]].self, forKey: .appTags) ?? [:] |
| | | tagOrder = try container.decodeIfPresent([String].self, forKey: .tagOrder) ?? [] |
| | | containerAppOrder = try container.decodeIfPresent( |
| | | [String: [String]].self, |
| | | forKey: .containerAppOrder |
| | | ) ?? [:] |
| | | uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? [] |
| | | uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:] |
| | | appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:] |
| | |
| | | for path in uncommonAppPaths where uncommonSources[path] == nil { |
| | | uncommonSources[path] = .manual |
| | | } |
| | | containerAppOrder = TagDatabase.normalizedContainerAppOrder( |
| | | containerAppOrder, |
| | | tags: tags, |
| | | appTags: appTags |
| | | ) |
| | | } |
| | | |
| | | var hasUserTagAssignments: Bool { |
| | |
| | | |
| | | static func load() -> Store { |
| | | guard let data = try? Data(contentsOf: storeURL), |
| | | let store = try? JSONDecoder().decode(Store.self, from: data) |
| | | var store = try? JSONDecoder().decode(Store.self, from: data) |
| | | else { return Store() } |
| | | if normalizeContainerAppOrder(in: &store) { |
| | | save(store) |
| | | } |
| | | return store |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | static func save(_ store: Store) { |
| | | var normalizedStore = store |
| | | _ = normalizeContainerAppOrder(in: &normalizedStore) |
| | | let encoder = JSONEncoder() |
| | | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
| | | guard let data = try? encoder.encode(store) else { return } |
| | | guard let data = try? encoder.encode(normalizedStore) else { return } |
| | | try? data.write(to: storeURL, options: .atomic) |
| | | } |
| | | |
| | | static func normalizedAppOrderPaths(_ paths: [String]) -> [String] { |
| | | var seen = Set<String>() |
| | | return paths.compactMap { rawPath in |
| | | let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | guard !path.isEmpty, seen.insert(path).inserted else { return nil } |
| | | return path |
| | | } |
| | | } |
| | | |
| | | static func normalizedContainerID(_ rawValue: String, tags: [String: TagDef] = [:]) -> String { |
| | | let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | guard !value.isEmpty else { return "" } |
| | | if value == AppContainerID.uncategorized |
| | | || value == AppContainerID.appleBuiltIn |
| | | || value.hasPrefix("tag:") |
| | | || value.hasPrefix("system:") { |
| | | return value |
| | | } |
| | | if value == "Other" || value == tr("group.uncategorized") { |
| | | return AppContainerID.uncategorized |
| | | } |
| | | if value == "Mac自带" || value == tr("group.appleBuiltIn") { |
| | | return AppContainerID.appleBuiltIn |
| | | } |
| | | return AppContainerID.forTag(value, definition: tags[value]) |
| | | } |
| | | |
| | | static func normalizedContainerAppOrder( |
| | | _ order: [String: [String]], |
| | | tags: [String: TagDef] = [:], |
| | | appTags: [String: [String]]? = nil, |
| | | validAppPaths: Set<String>? = nil |
| | | ) -> [String: [String]] { |
| | | var normalized: [String: [String]] = [:] |
| | | for (rawContainerID, rawPaths) in order { |
| | | let containerID = normalizedContainerID(rawContainerID, tags: tags) |
| | | guard !containerID.isEmpty, |
| | | isContainerOrderAllowed(containerID, tags: tags) |
| | | else { continue } |
| | | |
| | | var paths = normalizedAppOrderPaths(rawPaths) |
| | | if let validAppPaths { |
| | | paths = paths.filter { validAppPaths.contains($0) } |
| | | } |
| | | if let appTags { |
| | | paths = paths.filter { |
| | | pathBelongsToContainer( |
| | | path: $0, |
| | | containerID: containerID, |
| | | tags: tags, |
| | | appTags: appTags |
| | | ) |
| | | } |
| | | } |
| | | guard !paths.isEmpty else { continue } |
| | | |
| | | if let existing = normalized[containerID] { |
| | | normalized[containerID] = normalizedAppOrderPaths(existing + paths) |
| | | } else { |
| | | normalized[containerID] = paths |
| | | } |
| | | } |
| | | return normalized |
| | | } |
| | | |
| | | @discardableResult |
| | | static func normalizeContainerAppOrder( |
| | | in store: inout Store, |
| | | validAppPaths: Set<String>? = nil |
| | | ) -> Bool { |
| | | let original = store.containerAppOrder |
| | | store.containerAppOrder = normalizedContainerAppOrder( |
| | | store.containerAppOrder, |
| | | tags: store.tags, |
| | | appTags: store.appTags, |
| | | validAppPaths: validAppPaths |
| | | ) |
| | | return original != store.containerAppOrder |
| | | } |
| | | |
| | | static func migrateContainerAppOrderKey( |
| | | in store: inout Store, |
| | | from oldContainerID: String, |
| | | to newContainerID: String |
| | | ) { |
| | | guard oldContainerID != newContainerID else { return } |
| | | let oldKey = normalizedContainerID(oldContainerID, tags: store.tags) |
| | | let newKey = normalizedContainerID(newContainerID, tags: store.tags) |
| | | guard !oldKey.isEmpty, !newKey.isEmpty, |
| | | let oldOrder = store.containerAppOrder.removeValue(forKey: oldKey) |
| | | else { return } |
| | | |
| | | let mergedOrder = (store.containerAppOrder[newKey] ?? []) + oldOrder |
| | | store.containerAppOrder[newKey] = normalizedAppOrderPaths(mergedOrder) |
| | | } |
| | | |
| | | static func removeContainerAppOrder( |
| | | in store: inout Store, |
| | | containerID: String |
| | | ) { |
| | | let key = normalizedContainerID(containerID, tags: store.tags) |
| | | guard !key.isEmpty else { return } |
| | | store.containerAppOrder.removeValue(forKey: key) |
| | | } |
| | | |
| | | private static func isContainerOrderAllowed(_ containerID: String, tags: [String: TagDef]) -> Bool { |
| | | if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn { |
| | | return true |
| | | } |
| | | if containerID.hasPrefix("tag:") { |
| | | let tagName = String(containerID.dropFirst("tag:".count)) |
| | | return tags[tagName] != nil |
| | | } |
| | | if containerID.hasPrefix("system:") { |
| | | let rawValue = String(containerID.dropFirst("system:".count)) |
| | | guard let categoryID = SmartCategoryID(rawValue: rawValue) else { return false } |
| | | return tags.values.contains { $0.systemCategoryID == categoryID } |
| | | } |
| | | return false |
| | | } |
| | | |
| | | private static func pathBelongsToContainer( |
| | | path: String, |
| | | containerID: String, |
| | | tags: [String: TagDef], |
| | | appTags: [String: [String]] |
| | | ) -> Bool { |
| | | if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn { |
| | | return true |
| | | } |
| | | if containerID.hasPrefix("tag:") { |
| | | let tagName = String(containerID.dropFirst("tag:".count)) |
| | | return appTags[path]?.contains(tagName) == true |
| | | } |
| | | if containerID.hasPrefix("system:") { |
| | | let rawValue = String(containerID.dropFirst("system:".count)) |
| | | guard let categoryID = SmartCategoryID(rawValue: rawValue), |
| | | let tagName = tags.first(where: { $0.value.systemCategoryID == categoryID })?.key |
| | | else { return false } |
| | | return appTags[path]?.contains(tagName) == true |
| | | } |
| | | return false |
| | | } |
| | | |
| | | static func noteFingerprint(_ value: String) -> String { |
| | |
| | | let tags: [String: TagDef] |
| | | let appTags: [String: [String]] |
| | | let tagOrder: [String] |
| | | let containerAppOrder: [String: [String]] |
| | | let uncommonAppPaths: [String] |
| | | let uncommonSources: [String: UncommonSource] |
| | | let disabledSystemCategoryIDs: [SmartCategoryID] |
| | |
| | | tags: store.tags, |
| | | appTags: store.appTags.mapValues(uniqueOrdered), |
| | | tagOrder: uniqueOrdered(store.tagOrder), |
| | | containerAppOrder: normalizedContainerAppOrder( |
| | | store.containerAppOrder, |
| | | tags: store.tags, |
| | | appTags: store.appTags |
| | | ), |
| | | uncommonAppPaths: store.uncommonAppPaths.sorted(), |
| | | uncommonSources: store.uncommonSources, |
| | | disabledSystemCategoryIDs: store.disabledSystemCategoryIDs |
| | |
| | | |
| | | private static func renameTagKey(in store: inout Store, from oldName: String, to newName: String) { |
| | | guard oldName != newName, let tagDef = store.tags.removeValue(forKey: oldName) else { return } |
| | | let oldContainerID = AppContainerID.forTag(oldName, definition: tagDef) |
| | | let newContainerID = AppContainerID.forTag(newName, definition: tagDef) |
| | | store.tags[newName] = tagDef |
| | | store.tagOrder = uniqueOrdered(store.tagOrder.map { $0 == oldName ? newName : $0 }) |
| | | |
| | |
| | | store.appTags[path] = renamedTags |
| | | } |
| | | } |
| | | migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID) |
| | | _ = normalizeContainerAppOrder(in: &store) |
| | | } |
| | | |
| | | private static func uniqueOrdered(_ values: [String]) -> [String] { |
| | |
| | | let previousStore = loadWithEnsuredCategoryScheme() |
| | | var store = previousStore |
| | | store.appTags = [:] |
| | | store.containerAppOrder = [:] |
| | | saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | |
| | | /// Tag names in display order. Falls back to alpha sort if no custom order. |
| | | static func orderedTagNames() -> [String] { |
| | | let store = TagDatabase.load() |
| | | return orderedTagNames(in: store) |
| | | } |
| | | |
| | | static func orderedTagNames(in store: TagDatabase.Store) -> [String] { |
| | | let ordered = store.tagOrder.filter { store.tags[$0] != nil } |
| | | let remaining = store.tags.keys.filter { !ordered.contains($0) }.sorted() |
| | | return ordered + remaining |
| | |
| | | store, |
| | | previous: previousStore, |
| | | reason: "reorder-tags" |
| | | ) |
| | | } |
| | | |
| | | /// Persist the visible app order for one stable container. |
| | | static func reorderApps(inContainer containerID: String, orderedPaths: [String]) { |
| | | var store = TagDatabase.load() |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | let previousStore = store |
| | | |
| | | let key = TagDatabase.normalizedContainerID(containerID, tags: store.tags) |
| | | guard !key.isEmpty else { return } |
| | | |
| | | let normalizedPaths = TagDatabase.normalizedAppOrderPaths(orderedPaths) |
| | | if normalizedPaths.isEmpty { |
| | | store.containerAppOrder.removeValue(forKey: key) |
| | | } else { |
| | | store.containerAppOrder[key] = normalizedPaths |
| | | } |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | |
| | | guard store.containerAppOrder != previousStore.containerAppOrder else { return } |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | | reason: "reorder-apps" |
| | | ) |
| | | } |
| | | |
| | |
| | | if store.knownAppPaths.isEmpty { |
| | | let seededDefaultNotes = seedDefaultAppleAppNotes(for: apps, in: &store) |
| | | let markedUncommon = markUnfamiliarAppleAppsAsUncommon(apps, in: &store) |
| | | let normalizedOrder = TagDatabase.normalizeContainerAppOrder( |
| | | in: &store, |
| | | validAppPaths: scannedPaths |
| | | ) |
| | | store.knownAppPaths = scannedPaths.sorted() |
| | | if apps.isEmpty == false || seededDefaultNotes || markedUncommon { |
| | | if apps.isEmpty == false || seededDefaultNotes || markedUncommon || normalizedOrder { |
| | | TagDatabase.save(store) |
| | | } |
| | | return store |
| | |
| | | } |
| | | store.knownAppPaths = knownPaths.subtracting(removedPaths).sorted() |
| | | } |
| | | let normalizedOrder = TagDatabase.normalizeContainerAppOrder( |
| | | in: &store, |
| | | validAppPaths: scannedPaths |
| | | ) |
| | | |
| | | guard !newPaths.isEmpty else { |
| | | if !removedPaths.isEmpty { |
| | | if !removedPaths.isEmpty || normalizedOrder { |
| | | TagDatabase.save(store) |
| | | } |
| | | return store |
| | |
| | | _ = seedDefaultAppleAppNotes(for: newApps, in: &store) |
| | | store.uncommonAppPaths = uncommonPaths.sorted() |
| | | store.knownAppPaths = Set(store.knownAppPaths).union(newPaths).sorted() |
| | | _ = TagDatabase.normalizeContainerAppOrder( |
| | | in: &store, |
| | | validAppPaths: scannedPaths |
| | | ) |
| | | TagDatabase.save(store) |
| | | return store |
| | | } |
| | |
| | | let previousStore = store |
| | | // Update tag definition |
| | | if let def = store.tags.removeValue(forKey: oldName) { |
| | | let oldContainerID = AppContainerID.forTag(oldName, definition: def) |
| | | let newContainerID = AppContainerID.forTag(newName, definition: def) |
| | | store.tags[newName] = def |
| | | TagDatabase.migrateContainerAppOrderKey( |
| | | in: &store, |
| | | from: oldContainerID, |
| | | to: newContainerID |
| | | ) |
| | | } |
| | | store.tagOrder = store.tagOrder.map { $0 == oldName ? newName : $0 } |
| | | // Update all app assignments |
| | |
| | | store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String }) |
| | | } |
| | | } |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | |
| | | static func deleteTagCompletely(_ tag: String) { |
| | | var store = TagDatabase.load() |
| | | let previousStore = store |
| | | if let removedTag = store.tags.removeValue(forKey: tag), |
| | | let categoryID = removedTag.systemCategoryID, |
| | | let removedTag = store.tags.removeValue(forKey: tag) |
| | | if let categoryID = removedTag?.systemCategoryID, |
| | | !store.disabledSystemCategoryIDs.contains(categoryID) { |
| | | store.disabledSystemCategoryIDs.append(categoryID) |
| | | } |
| | | if let removedTag { |
| | | TagDatabase.removeContainerAppOrder( |
| | | in: &store, |
| | | containerID: AppContainerID.forTag(tag, definition: removedTag) |
| | | ) |
| | | } else { |
| | | TagDatabase.removeContainerAppOrder( |
| | | in: &store, |
| | | containerID: AppContainerID.tag(tag) |
| | | ) |
| | | } |
| | | store.tagOrder.removeAll { $0 == tag } |
| | | for (path, var tags) in store.appTags { |
| | |
| | | store.appTags[path] = tags |
| | | } |
| | | } |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>7.8.31</string> |
| | | <string>7.9.0</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260611.1928</string> |
| | | <string>20260611.2212</string> |
| | | <key>LSApplicationCategoryType</key> |
| | | <string>public.app-category.utilities</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | |
| | | store.tags = [:] |
| | | store.appTags = [:] |
| | | store.tagOrder = [] |
| | | store.containerAppOrder = [:] |
| | | store.disabledSystemCategoryIDs = [] |
| | | } |
| | | |
| | |
| | | # TagLauncher Changelog |
| | | |
| | | ## [7.9.0] — 2026-06-11 |
| | | |
| | | - 新增容器内 App 手动拖拽排序:同一容器内拖动 App 可改变显示顺序,不改变 App 与标签的归属关系 |
| | | - 新增稳定容器排序数据 `containerAppOrder`,使用非本地化容器 ID 保存顺序;刷新、重启、语言切换、导入导出、恢复上一方案和 SmartStart 替换时按统一规则保留、迁移或清理 |
| | | - App Grid 拖拽链路增加同容器排序意图和插入位置提示;跨容器移动、Option 复制、拖空白移除、Apple 内置保护继续沿用原有逻辑 |
| | | - 容器排序纳入分类方案 fingerprint 和自动上一方案快照,排序变化会被备份/恢复体系识别 |
| | | - 新增 `Scripts/app_ordering_data_qa.sh`,覆盖排序 schema、导入导出、备份恢复、SmartStart 清理、ContentView/AppGrid wiring 和拖拽 payload 静态门禁 |
| | | - 版本号更新为 `7.9.0`,Build 更新为 `20260611.2212` |
| | | |
| | | ## [7.8.31] — 2026-06-11 |
| | | |
| | | - 设置页 Data 面板按原型拆分为两个独立边框区域:“布局初始化”和“自定义布局”分开展示,避免重置初始化、恢复、导入导出混在同一块区域里 |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | DATA_LAYER_SWIFT="$ROOT_DIR/Apptag/DataLayer.swift" |
| | | SMART_START_SWIFT="$ROOT_DIR/Apptag/SmartCategorization/SmartStartService.swift" |
| | | CONTENT_VIEW_SWIFT="$ROOT_DIR/Apptag/ContentView.swift" |
| | | APP_GRID_SWIFT="$ROOT_DIR/Apptag/AppGridCollectionView.swift" |
| | | APP_DRAG_SWIFT="$ROOT_DIR/Apptag/AppDragCoordinator.swift" |
| | | |
| | | fail() { |
| | | printf 'FAIL: %s\n' "$*" >&2 |
| | | exit 1 |
| | | } |
| | | |
| | | [[ -f "$DATA_LAYER_SWIFT" ]] || fail "missing DataLayer.swift at $DATA_LAYER_SWIFT" |
| | | [[ -f "$SMART_START_SWIFT" ]] || fail "missing SmartStartService.swift at $SMART_START_SWIFT" |
| | | [[ -f "$CONTENT_VIEW_SWIFT" ]] || fail "missing ContentView.swift at $CONTENT_VIEW_SWIFT" |
| | | [[ -f "$APP_GRID_SWIFT" ]] || fail "missing AppGridCollectionView.swift at $APP_GRID_SWIFT" |
| | | [[ -f "$APP_DRAG_SWIFT" ]] || fail "missing AppDragCoordinator.swift at $APP_DRAG_SWIFT" |
| | | |
| | | python3 - "$DATA_LAYER_SWIFT" "$SMART_START_SWIFT" "$CONTENT_VIEW_SWIFT" "$APP_GRID_SWIFT" "$APP_DRAG_SWIFT" <<'PY' |
| | | import json |
| | | import pathlib |
| | | import re |
| | | import sys |
| | | |
| | | data_layer = pathlib.Path(sys.argv[1]) |
| | | smart_start = pathlib.Path(sys.argv[2]) |
| | | content_view = pathlib.Path(sys.argv[3]) |
| | | app_grid = pathlib.Path(sys.argv[4]) |
| | | app_drag = pathlib.Path(sys.argv[5]) |
| | | text = data_layer.read_text(encoding="utf-8") |
| | | smart_start_text = smart_start.read_text(encoding="utf-8") |
| | | content_text = content_view.read_text(encoding="utf-8") |
| | | app_grid_text = app_grid.read_text(encoding="utf-8") |
| | | app_drag_text = app_drag.read_text(encoding="utf-8") |
| | | |
| | | |
| | | def fail(message: str) -> None: |
| | | print(f"FAIL: {message}", file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | |
| | | def require(pattern: str, message: str, source: str = text) -> None: |
| | | if not re.search(pattern, source, re.S): |
| | | fail(message) |
| | | |
| | | |
| | | def extract_braced_block(marker_pattern: str, label: str, source: str = text) -> str: |
| | | match = re.search(marker_pattern, source) |
| | | if not match: |
| | | fail(f"missing {label}") |
| | | |
| | | start = source.find("{", match.end()) |
| | | if start == -1: |
| | | fail(f"missing opening brace for {label}") |
| | | |
| | | depth = 0 |
| | | for index in range(start, len(source)): |
| | | char = source[index] |
| | | if char == "{": |
| | | depth += 1 |
| | | elif char == "}": |
| | | depth -= 1 |
| | | if depth == 0: |
| | | return source[start + 1:index] |
| | | |
| | | fail(f"missing closing brace for {label}") |
| | | |
| | | |
| | | store_block = extract_braced_block(r"\bstruct\s+Store\s*:\s*Codable\b", "TagDatabase.Store") |
| | | coding_keys_block = extract_braced_block( |
| | | r"\benum\s+CodingKeys\s*:\s*String\s*,\s*CodingKey\b", |
| | | "Store.CodingKeys", |
| | | store_block, |
| | | ) |
| | | fingerprint_block = extract_braced_block( |
| | | r"\bprivate\s+struct\s+CategorySchemeFingerprint\s*:\s*Equatable\b", |
| | | "CategorySchemeFingerprint", |
| | | ) |
| | | fingerprint_func = extract_braced_block( |
| | | r"\bprivate\s+static\s+func\s+categorySchemeFingerprint\s*\(\s*for\s+store\s*:\s*Store\s*\)\s*->\s*CategorySchemeFingerprint\b", |
| | | "categorySchemeFingerprint(for:)", |
| | | ) |
| | | reorder_apps_func = extract_braced_block( |
| | | r"\bstatic\s+func\s+reorderApps\s*\(\s*inContainer\s+containerID\s*:\s*String\s*,\s*orderedPaths\s*:\s*\[String\]\s*\)", |
| | | "TagEditor.reorderApps(inContainer:orderedPaths:)", |
| | | ) |
| | | reset_uncategorized_func = extract_braced_block( |
| | | r"\bstatic\s+func\s+resetAppTagAssignmentsToUncategorized\s*\(\s*\)\s*->\s*Store\b", |
| | | "resetAppTagAssignmentsToUncategorized()", |
| | | ) |
| | | content_drop_app_func = extract_braced_block( |
| | | r"\bprivate\s+func\s+dropApp\s*\(\s*path\s*:\s*String\s*,\s*sourceTag\s*:\s*String\s*,\s*targetTag\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)", |
| | | "ContentView.dropApp(path:sourceTag:targetTag:copy:)", |
| | | content_text, |
| | | ) |
| | | app_grid_perform_drop_func = extract_braced_block( |
| | | r"\bfunc\s+performDrop\s*\(\s*path\s*:\s*String\s*,\s*source\s*:\s*String\s*,\s*sourceContainerID\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)", |
| | | "AppGridGroupCardView.performDrop(path:source:sourceContainerID:copy:)", |
| | | app_grid_text, |
| | | ) |
| | | |
| | | require( |
| | | r"\bvar\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]\s*=\s*\[:\]", |
| | | "Store must persist containerAppOrder as [String: [String]] with an empty default", |
| | | store_block, |
| | | ) |
| | | require( |
| | | r"\bcase\s+containerAppOrder\b", |
| | | "Store.CodingKeys must include containerAppOrder for JSON roundtrip", |
| | | coding_keys_block, |
| | | ) |
| | | require( |
| | | r"containerAppOrder\s*=\s*try\s+container\.decodeIfPresent\(\s*\[String\s*:\s*\[String\]\]\.self\s*,\s*forKey\s*:\s*\.containerAppOrder\s*\)\s*\?\?\s*\[:\]", |
| | | "Store.init(from:) must decode missing containerAppOrder as an empty dictionary", |
| | | store_block, |
| | | ) |
| | | |
| | | if re.search(r"\bfunc\s+encode\s*\(\s*to\s+encoder\s*:\s*Encoder\s*\)", store_block): |
| | | require( |
| | | r"\.encode\(\s*containerAppOrder\s*,\s*forKey\s*:\s*\.containerAppOrder\s*\)", |
| | | "custom Store.encode(to:) must encode containerAppOrder", |
| | | store_block, |
| | | ) |
| | | |
| | | require( |
| | | r"\blet\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]", |
| | | "CategorySchemeFingerprint must include containerAppOrder", |
| | | fingerprint_block, |
| | | ) |
| | | require( |
| | | r"containerAppOrder\s*:", |
| | | "categorySchemeFingerprint(for:) must populate containerAppOrder", |
| | | fingerprint_func, |
| | | ) |
| | | require( |
| | | r"store\.containerAppOrder\b", |
| | | "categorySchemeFingerprint(for:) must derive containerAppOrder from Store.containerAppOrder", |
| | | fingerprint_func, |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+normalizedContainerID\s*\(", |
| | | "TagDatabase must expose normalizedContainerID(_:) so UI never keys order by display name", |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+normalizedContainerAppOrder\s*\(", |
| | | "TagDatabase must normalize containerAppOrder keys and path arrays", |
| | | ) |
| | | require( |
| | | r"let\s+effectiveTagDefinitions\s*=\s*tagDefinitions\s*\?\?\s*loadedStore\?\.tags\s*\?\?\s*\[:\](?:(?!\n\s*var\s+dict).)*normalizedContainerAppOrder\s*\([^)]*tags\s*:\s*effectiveTagDefinitions", |
| | | "AppIndexer.group must normalize order with effective tag definitions so tag/system container order survives reload", |
| | | ) |
| | | require( |
| | | r"normalizedContainerAppOrder\s*\([^)]*appTags\s*:\s*appTagsByPath\s*,\s*validAppPaths\s*:\s*validAppPaths", |
| | | "AppIndexer.group must normalize display order against current visible app paths and memberships", |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+normalizeContainerAppOrder\s*\(", |
| | | "TagDatabase must normalize/prune containerAppOrder during lifecycle writes", |
| | | ) |
| | | require( |
| | | r"saveUserCategorySchemeMutation\s*\([^)]*reason\s*:\s*\"reorder-apps\"", |
| | | "TagEditor.reorderApps must save through category scheme mutation with reason reorder-apps", |
| | | reorder_apps_func, |
| | | ) |
| | | require( |
| | | r"store\.containerAppOrder\s*=\s*\[:\]", |
| | | "reset to uncategorized must clear old containerAppOrder", |
| | | reset_uncategorized_func, |
| | | ) |
| | | |
| | | require( |
| | | r"\bstatic\s+func\s+exportTo\s*\([^)]*\)\s+throws\s*\{(?:(?!\n\s*static\s+func).)*JSONEncoder\(\)\.encode\(\s*store\s*\)", |
| | | "exportTo(_:) must encode the full Store so containerAppOrder is exported", |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+importFrom\s*\([^)]*\)\s+throws\s*->\s*Store\s*\{(?:(?!\n\s*static\s+func).)*JSONDecoder\(\)\.decode\(\s*Store\.self\s*,\s*from\s*:\s*data\s*\)", |
| | | "importFrom(_:) must decode the full Store so containerAppOrder is imported", |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+backup\s*\([^)]*\)\s*->\s*URL\?\s*\{(?:(?!\n\s*static\s+func).)*encoder\.encode\(\s*store\s*\)", |
| | | "backup(_:reason:in:) must encode the full Store so ordering is included in snapshots", |
| | | ) |
| | | require( |
| | | r"\bstatic\s+func\s+restore\s*\(\s*fromBackupAt\s+path\s*:\s*String\s*\)\s*->\s*Bool\s*\{(?:(?!\n\s*(?:static|private)\s+func).)*JSONDecoder\(\)\.decode\(\s*Store\.self\s*,\s*from\s*:\s*data\s*\)", |
| | | "restore(fromBackupAt:) must decode the full Store so ordering can be restored", |
| | | ) |
| | | require( |
| | | r"replaceExistingScheme\s*\{(?:(?!\n\s*\}).)*store\.containerAppOrder\s*=\s*\[:\]", |
| | | "SmartStart replaceExistingScheme must clear stale containerAppOrder", |
| | | smart_start_text, |
| | | ) |
| | | require( |
| | | r"let\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]", |
| | | "AppLibrarySnapshot must carry containerAppOrder from the same Store snapshot", |
| | | pathlib.Path(sys.argv[1]).with_name("AppLibraryController.swift").read_text(encoding="utf-8"), |
| | | ) |
| | | require( |
| | | r"onReorderApps\s*:\s*\{\s*containerID\s*,\s*orderedPaths\s+in(?:(?!\n\s*\}).)*reorderApps\s*\(\s*inContainer\s*:\s*containerID\s*,\s*orderedPaths\s*:\s*orderedPaths\s*\)", |
| | | "ContentView must wire AppGrid onReorderApps into the reorder write path", |
| | | content_text, |
| | | ) |
| | | require( |
| | | r"TagEditor\.reorderApps\s*\(\s*inContainer\s*:\s*key\s*,\s*orderedPaths\s*:\s*paths\s*\)", |
| | | "ContentView.reorderApps must persist the normalized container order", |
| | | content_text, |
| | | ) |
| | | require( |
| | | r"sourceContainerID", |
| | | "AppGrid drag payload must include sourceContainerID for language-stable same-container checks", |
| | | app_grid_text, |
| | | ) |
| | | require( |
| | | r"activeReorderPath\s*\(\s*in\s+containerID\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)", |
| | | "AppGrid coordinator must expose a same-container reorder guard", |
| | | app_grid_text, |
| | | ) |
| | | require( |
| | | r"shouldCancelEmptyDropForActiveReorder\s*\([^)]*path\s*:\s*String[^)]*screenPoint\s*:\s*NSPoint[^)]*copy\s*:\s*Bool", |
| | | "AppGrid must cancel near-edge same-container reorder drops instead of routing them to remove-tag empty drop", |
| | | app_grid_text, |
| | | ) |
| | | require( |
| | | r"if\s+sourceContainerID\s*==\s*group\.containerID\s*\{", |
| | | "AppGrid same-container drops, including no-op and Option drops, must be swallowed before cross-container move/copy logic", |
| | | app_grid_perform_drop_func, |
| | | ) |
| | | same_container_index = app_grid_perform_drop_func.find("sourceContainerID == group.containerID") |
| | | same_return_index = app_grid_perform_drop_func.find("return", same_container_index) |
| | | cross_drop_index = app_grid_perform_drop_func.find("coordinator?.onDropApp") |
| | | if not (0 <= same_container_index < same_return_index < cross_drop_index): |
| | | fail("AppGrid same-container drop must return before coordinator.onDropApp") |
| | | if "coordinator?.endAppIconDrag()" not in app_grid_perform_drop_func[same_container_index:same_return_index]: |
| | | fail("AppGrid same-container drop must end drag state before returning") |
| | | if "clearReorderInsertion()" not in app_grid_perform_drop_func[same_container_index:same_return_index]: |
| | | fail("AppGrid same-container drop must clear insertion state before returning") |
| | | source_guard_index = content_drop_app_func.find("guard sourceTag != targetTag else { return }") |
| | | apple_guard_index = content_drop_app_func.find("isAppleBuiltInDropTarget") |
| | | if not (0 <= source_guard_index < apple_guard_index): |
| | | fail("ContentView.dropApp must no-op same source/target before Apple built-in warning") |
| | | require( |
| | | r"parts\.dropFirst\(2\)\.first\s*\?\?\s*\"\"", |
| | | "AppDragCoordinator must parse sourceContainerID from the drag payload", |
| | | app_drag_text, |
| | | ) |
| | | |
| | | old_fixture = json.loads( |
| | | """ |
| | | { |
| | | "version": 1, |
| | | "tags": {"Work": {"color": 1}}, |
| | | "appTags": {"/Applications/A.app": ["Work"]}, |
| | | "tagOrder": ["Work"] |
| | | } |
| | | """ |
| | | ) |
| | | new_fixture = json.loads( |
| | | """ |
| | | { |
| | | "version": 1, |
| | | "tags": {"Work": {"color": 1}}, |
| | | "appTags": {"/Applications/A.app": ["Work"], "/Applications/B.app": ["Work"]}, |
| | | "tagOrder": ["Work"], |
| | | "containerAppOrder": {"tag:Work": ["/Applications/B.app", "/Applications/A.app"]} |
| | | } |
| | | """ |
| | | ) |
| | | |
| | | if "containerAppOrder" in old_fixture: |
| | | fail("old fixture unexpectedly contains containerAppOrder") |
| | | if new_fixture.get("containerAppOrder", {}).get("tag:Work") != [ |
| | | "/Applications/B.app", |
| | | "/Applications/A.app", |
| | | ]: |
| | | fail("new fixture does not preserve ordered app paths") |
| | | |
| | | print( |
| | | "PASS app ordering QA: Store decode/default, JSON roundtrip, " |
| | | "fingerprint, import/export, backup/restore, SmartStart reset, " |
| | | "ContentView wiring, and AppGrid stable payload gates are present" |
| | | ) |
| | | PY |
| | |
| | | log "PASS no TagLauncher Dock tile" |
| | | } |
| | | |
| | | click_taglauncher_dock_tile() { |
| | | local coords x y |
| | | coords="$(osascript <<'OSA' |
| | | taglauncher_dock_tile_coords() { |
| | | osascript <<'OSA' |
| | | tell application "System Events" |
| | | tell process "Dock" |
| | | repeat with itemRef in UI elements of list 1 |
| | |
| | | end tell |
| | | end tell |
| | | OSA |
| | | )" |
| | | read -r x y <<<"$coords" |
| | | } |
| | | |
| | | clamped_click_coords() { |
| | | local x="$1" |
| | | local y="$2" |
| | | local screen_line screen_x screen_y screen_w screen_h min_x max_x min_y max_y |
| | | if [[ -n "${screens_swift:-}" && -f "$screens_swift" ]]; then |
| | | screen_line="$(swift "$screens_swift" | awk -F'|' -v x="$x" '$4 <= x && x <= ($4 + $6) { print; exit }')" |
| | | if [[ -n "$screen_line" ]]; then |
| | | IFS='|' read -r _ _ _ screen_x screen_y screen_w screen_h <<<"$screen_line" |
| | | min_x=$((screen_x + 4)) |
| | | max_x=$((screen_x + screen_w - 4)) |
| | | min_y=$((screen_y + 4)) |
| | | max_y=$((screen_y + screen_h - 4)) |
| | | if (( x < min_x )); then x="$min_x"; fi |
| | | if (( x > max_x )); then x="$max_x"; fi |
| | | if (( y < min_y )); then y="$min_y"; fi |
| | | if (( y > max_y )); then y="$max_y"; fi |
| | | fi |
| | | fi |
| | | printf '%s %s\n' "$x" "$y" |
| | | } |
| | | |
| | | click_taglauncher_dock_tile() { |
| | | local coords x y |
| | | coords="$(taglauncher_dock_tile_coords)" |
| | | read -r x y <<<"$(clamped_click_coords ${coords%% *} ${coords#* })" |
| | | move_xy "$x" "$y" |
| | | sleep 0.8 |
| | | if coords="$(taglauncher_dock_tile_coords 2>/dev/null)"; then |
| | | read -r x y <<<"$(clamped_click_coords ${coords%% *} ${coords#* })" |
| | | fi |
| | | click_xy "$x" "$y" |
| | | sleep 0.15 |
| | | osascript <<'OSA' >/dev/null 2>&1 || true |
| | | tell application "System Events" |
| | | tell process "Dock" |
| | | repeat with itemRef in UI elements of list 1 |
| | | try |
| | | if (name of itemRef as text) is "TagLauncher" then |
| | | click itemRef |
| | | return |
| | | end if |
| | | end try |
| | | end repeat |
| | | end tell |
| | | end tell |
| | | OSA |
| | | } |
| | | |
| | | open_overlay_from_dock_with_retry() { |
| | |
| | | return 1 |
| | | } |
| | | |
| | | open_fullscreen_overlay_with_retry() { |
| | | local output="" |
| | | for _ in {1..3}; do |
| | | send_main_hotkey |
| | | sleep 0.4 |
| | | if output="$(wait_swift_assert fullscreen-overlay 2>&1)"; then |
| | | printf '%s\n' "$output" |
| | | return 0 |
| | | fi |
| | | done |
| | | printf '%s\n' "$output" >&2 |
| | | return 1 |
| | | } |
| | | |
| | | show_overlay() { |
| | | send_main_hotkey |
| | | if wait_swift_assert overlay >/dev/null 2>&1; then |
| | |
| | | prepare_isolated_app_instance |
| | | start_fullscreen_qa_target |
| | | move_pointer_to_fullscreen_target |
| | | send_main_hotkey |
| | | wait_swift_assert fullscreen-overlay |
| | | open_fullscreen_overlay_with_retry |
| | | assert_fullscreen_overlay_stable |
| | | log "==> QA fullscreen Space: quick search from appgrid does not switch Space" |
| | | open_appgrid_quick_search_with_retry |
| | |
| | | log "==> QA 4/7 and 5/7: appgrid-space quick search and double-Esc behavior" |
| | | open_appgrid_quick_search_with_retry |
| | | send_keycode 53 |
| | | sleep 0.4 |
| | | swift_assert overlay |
| | | wait_swift_assert overlay |
| | | send_keycode 53 |
| | | sleep 0.4 |
| | | swift_assert no-overlay |
| | | wait_swift_assert no-overlay |
| | | |
| | | log "==> QA 4/7: appgrid scroll keeps Space and Esc keyboard routing" |
| | | show_overlay |
| | |
| | | page_scroll_appgrid |
| | | open_appgrid_quick_search_with_retry |
| | | send_keycode 53 |
| | | sleep 0.25 |
| | | swift_assert overlay |
| | | wait_swift_assert overlay |
| | | done |
| | | page_scroll_appgrid |
| | | send_keycode 53 |
| | | sleep 0.35 |
| | | swift_assert no-overlay |
| | | wait_swift_assert no-overlay |
| | | |
| | | log "==> QA 5/7: clicking outside quick search closes search, not appgrid" |
| | | show_overlay |
| | |
| | | |
| | | ## Todo |
| | | |
| | | - [7.9.0 大需求] 容器内 App 手动拖拽排序。 |
| | | - 状态: 方案已确认,详细 TODO 和过程文档已归档到 `../Docs/7.90/`。 |
| | | - 版本: 后续实现从 `7.9.0` 开始记录,不混入 `7.8.x` 小版本。 |
| | | - 入口: `../Docs/7.90/README.md`、`../Docs/7.90/01-容器内App手动排序-TODO.md`、`../Docs/7.90/02-专题讨论纪要.md`。 |
| | | |
| | | - [发布后独立任务] 设置页 tab 容器 AppKit/自控化评估与实现。 |
| | | - 决策: 当前发布前 No-Go;保留 SwiftUI `TabView`,不改 QA 脚本。 |
| | | - 背景: QA 过程中曾观察到设置页顶部 tab 偶发折叠/消失,但目前不是稳定可复现 release blocker。 |
| | |
| | | |
| | | ## Done |
| | | |
| | | - [2026-06-11] 7.9.0 容器内 App 手动拖拽排序。 |
| | | - 结果: 同一容器内拖动 App 可改变显示顺序;排序保存为分类与布局方案的一部分,刷新、重启、语言切换、导入导出、恢复上一方案和 SmartStart 替换按统一规则保留、迁移或清理。 |
| | | - 范围: 新增稳定 `containerAppOrder`、容器 ID、排序持久化 helper、分类方案 fingerprint、App Grid 同容器插入位置提示和 `ContentView` 保存回调;跨容器移动、Option 复制、拖空白移除、Apple 内置保护、Quick Search 排名均不改。 |
| | | - 文档: `../docs/7.90/README.md`、`../docs/7.90/01-容器内App手动排序-TODO.md`、`../docs/7.90/07-实现与QA记录.md`。 |
| | | - 验证: `Scripts/app_ordering_data_qa.sh`、macOS 14 typecheck/build metadata、Quick Search 名称回归、SmartStart catalog QA、`Scripts/window_logic_qa.sh` 通过。 |
| | | - 版本: `7.9.0 (20260611.2212)`。 |
| | | |
| | | - [2026-06-09] 多语种 App 显示名通用加固。 |
| | | - 结果: 新增统一 `AppDisplayNameResolver`;App Grid 与 Quick Search 标题优先走当前语种/同语系/系统可见名/英文官方名/基础 bundle 名 fallback,内部 bundle 名和 bundle id 只作为搜索别名;`7.8.26` 进一步加固 `/Wrapper/`、`/Contents/Helpers/`、`.app/.../*.app` 内部 helper 过滤。 |
| | | - 范围: `AppInfo` 增加系统显示名与 bundle 显示名候选;App 扫描层和 Quick Search 文档层都过滤内部 helper;Quick Search 文档使用 resolver 生成别名;版本更新为 `7.8.26 (20260609.2004)`。 |