From a1ade5e8426d9cccd3c59f692d6fab55e67021fa Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 11 Jun 2026 22:49:58 +0800
Subject: [PATCH] Add 7.9 app ordering support
---
src/Scripts/window_logic_qa.sh | 83 +++
src/Apptag/AppGridCollectionView.swift | 265 ++++++++++++
src/TODO.md | 12
src/Apptag/AppDragCoordinator.swift | 17
src/Apptag/SmartCategorization/SmartStartService.swift | 1
docs/7.90/01-容器内App手动排序-TODO.md | 41 -
docs/7.90/README.md | 5
src/Apptag/ApptagApp.swift | 16
docs/7.90/07-实现与QA记录.md | 44 ++
docs/7.90/04-QA测试用例集.md | 9
src/Apptag/AppLibraryController.swift | 10
src/CHANGELOG.md | 9
src/Apptag/Info.plist | 4
src/Apptag/ContentView.swift | 42 +
src/Apptag/DataLayer.swift | 392 ++++++++++++++++++
src/Scripts/app_ordering_data_qa.sh | 284 +++++++++++++
16 files changed, 1,164 insertions(+), 70 deletions(-)
diff --git "a/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md" "b/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
index bd7751d..c299b9d 100644
--- "a/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
+++ "b/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
@@ -8,7 +8,7 @@
- 起始版本:`7.9.0`
- 需求级别:较大需求
-- 当前状态:方案确认,待开发
+- 当前状态:已实现,待最终用户体验验收
## 核心决策
@@ -20,47 +20,44 @@
## 数据层 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 自动化回归通过。
## 风险等级
diff --git "a/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md" "b/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
index a1f92eb..90b8c52 100644
--- "a/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
+++ "b/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
@@ -102,10 +102,11 @@
## 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
@@ -114,6 +115,12 @@
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. 发布阻断条件
- 顺序在刷新、重启、语言切换、导入导出、恢复上一方案任一路径丢失。
diff --git "a/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md" "b/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md"
new file mode 100644
index 0000000..01c4ed8
--- /dev/null
+++ "b/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md"
@@ -0,0 +1,44 @@
+# 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 或本机构建做最终体验验收,重点看拖拽插入线是否符合预期。
diff --git a/docs/7.90/README.md b/docs/7.90/README.md
index ddc1fc4..4c10f76 100644
--- a/docs/7.90/README.md
+++ b/docs/7.90/README.md
@@ -4,7 +4,7 @@
- 本目录用于归档 7.9.x 大需求的方案、TODO、过程纪要和后续验收资料。
- “容器内 App 手动拖拽排序”属于较大需求,版本记录从 `7.9.0` 开始,不继续放在 `7.8.x` 小版本序列里。
-- 当前只完成方案确认和任务拆解,尚未进入代码实现。
+- 当前已完成第一版实现和自动化 QA;后续只剩真实用户体验验收与是否进入发布候选的决策。
## 需求主题
@@ -25,9 +25,10 @@
- [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。
- 完成阶段性实现后,将验证结果继续补充到本目录,而不是散落在会话记录里。
diff --git a/src/Apptag/AppDragCoordinator.swift b/src/Apptag/AppDragCoordinator.swift
index 2dff55b..264706d 100644
--- a/src/Apptag/AppDragCoordinator.swift
+++ b/src/Apptag/AppDragCoordinator.swift
@@ -131,6 +131,7 @@
dragLayer.position = contentPoint
CATransaction.commit()
updateHoverTarget(at: screenPoint)
+ hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
return
}
@@ -144,6 +145,7 @@
)
dragWindow.setFrameOrigin(origin)
updateHoverTarget(at: screenPoint)
+ hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
}
func finishDrag(at screenPoint: NSPoint, copy: Bool) {
@@ -151,10 +153,17 @@
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
}
@@ -282,7 +291,9 @@
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 {
@@ -300,6 +311,10 @@
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 {
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index 54a218b..68e508c 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -46,6 +46,7 @@
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
@@ -78,6 +79,7 @@
onEditNote: onEditNote,
onDropApp: onDropApp,
onDropOutsideGroup: onDropOutsideGroup,
+ onReorderApps: onReorderApps,
onGroupActivate: onGroupActivate,
onScrollActivity: onScrollActivity,
onDragModeChange: onDragModeChange
@@ -107,9 +109,16 @@
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],
@@ -128,6 +137,7 @@
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
@@ -148,6 +158,7 @@
self.onEditNote = onEditNote
self.onDropApp = onDropApp
self.onDropOutsideGroup = onDropOutsideGroup
+ self.onReorderApps = onReorderApps
self.onGroupActivate = onGroupActivate
self.onScrollActivity = onScrollActivity
self.onDragModeChange = onDragModeChange
@@ -228,6 +239,78 @@
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,
@@ -291,6 +374,7 @@
NotificationCenter.default.removeObserver(scrollObserver)
}
scrollUnfreezeWorkItem?.cancel()
+ coordinator?.cancelAppIconDrag()
AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
}
@@ -337,6 +421,8 @@
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)
@@ -347,6 +433,14 @@
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)
}
@@ -386,6 +480,11 @@
}
private func handleScrollActivity() {
+ if AppDragCoordinator.shared.hasActiveDrag {
+ AppDragCoordinator.shared.cancelDrag()
+ }
+ coordinator?.cancelAppIconDrag()
+
if !scrollActivityIsActive {
scrollActivityIsActive = true
if coordinator?.setScrollBubbleDisabled(true) == true {
@@ -844,9 +943,11 @@
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 }
@@ -878,6 +979,7 @@
AppDragCoordinator.shared.unregister(id: dropTargetID)
group = nil
coordinator = nil
+ reorderInsertionIndex = nil
isMouseInside = false
isHovered = false
layer?.shadowOpacity = 0
@@ -982,19 +1084,71 @@
}
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() {
@@ -1011,6 +1165,7 @@
iconView.configure(
app: app,
sourceTag: group.name,
+ sourceContainerID: group.containerID,
iconSize: coordinator.iconSize,
showName: coordinator.showNames,
coordinator: coordinator
@@ -1018,6 +1173,100 @@
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() {
@@ -1142,6 +1391,7 @@
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
@@ -1166,12 +1416,14 @@
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
@@ -1191,6 +1443,7 @@
mouseDownEvent = nil
didStartDrag = false
isLongPressActive = false
+ sourceContainerID = ""
longPressWorkItem = nil
}
@@ -1293,6 +1546,12 @@
didStartDrag = true
longPressWorkItem?.cancel()
+ if let app {
+ coordinator?.beginAppIconDrag(
+ path: app.path.path,
+ sourceContainerID: sourceContainerID
+ )
+ }
AppDragCoordinator.shared.beginDrag(
image: makeDragImage(),
payload: dragPayload,
@@ -1309,11 +1568,11 @@
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
@@ -1355,7 +1614,7 @@
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) {
diff --git a/src/Apptag/AppLibraryController.swift b/src/Apptag/AppLibraryController.swift
index d8ebbd3..2a2136a 100644
--- a/src/Apptag/AppLibraryController.swift
+++ b/src/Apptag/AppLibraryController.swift
@@ -5,6 +5,8 @@
let quickSearchDocuments: [QuickSearchDocument]
let tagColors: [String: Int]
let tagOrder: [String]
+ let tagDefinitions: [String: TagDatabase.TagDef]
+ let containerAppOrder: [String: [String]]
}
struct AppLibraryRefreshResult {
@@ -77,7 +79,13 @@
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
+ )
)
}
}
diff --git a/src/Apptag/ApptagApp.swift b/src/Apptag/ApptagApp.swift
index c88dc4a..beb61cb 100644
--- a/src/Apptag/ApptagApp.swift
+++ b/src/Apptag/ApptagApp.swift
@@ -251,12 +251,15 @@
.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
@@ -270,7 +273,16 @@
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
}
}
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 231ed55..1afa3e3 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -348,6 +348,8 @@
@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
@@ -1131,6 +1133,9 @@
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)
@@ -1778,13 +1783,27 @@
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
}
@@ -2126,6 +2145,8 @@
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
@@ -2137,7 +2158,6 @@
}
guard tagColors[targetTag] != nil else { return }
- guard sourceTag != targetTag || copy else { return }
TagEditor.moveApp(
path: path,
from: sourceTag,
@@ -2156,6 +2176,18 @@
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)
}
@@ -2462,6 +2494,8 @@
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 {
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 1cc92f4..9b88064 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -41,6 +41,44 @@
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)
@@ -177,9 +215,24 @@
}
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
@@ -705,26 +758,65 @@
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
+ )
}
}
}
@@ -747,16 +839,60 @@
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)
}
}
@@ -802,6 +938,7 @@
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
@@ -818,6 +955,7 @@
case tags
case appTags
case tagOrder
+ case containerAppOrder
case uncommonAppPaths
case uncommonSources
case appOpenCounts
@@ -838,6 +976,10 @@
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) ?? [:]
@@ -858,6 +1000,11 @@
for path in uncommonAppPaths where uncommonSources[path] == nil {
uncommonSources[path] = .manual
}
+ containerAppOrder = TagDatabase.normalizedContainerAppOrder(
+ containerAppOrder,
+ tags: tags,
+ appTags: appTags
+ )
}
var hasUserTagAssignments: Bool {
@@ -911,8 +1058,11 @@
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
}
@@ -925,10 +1075,156 @@
}
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 {
@@ -969,6 +1265,7 @@
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]
@@ -1085,6 +1382,11 @@
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
@@ -1192,6 +1494,8 @@
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 })
@@ -1203,6 +1507,8 @@
store.appTags[path] = renamedTags
}
}
+ migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID)
+ _ = normalizeContainerAppOrder(in: &store)
}
private static func uniqueOrdered(_ values: [String]) -> [String] {
@@ -1239,6 +1545,7 @@
let previousStore = loadWithEnsuredCategoryScheme()
var store = previousStore
store.appTags = [:]
+ store.containerAppOrder = [:]
saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1446,6 +1753,10 @@
/// 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
@@ -1460,6 +1771,31 @@
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"
)
}
@@ -1630,8 +1966,12 @@
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
@@ -1646,9 +1986,13 @@
}
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
@@ -1673,6 +2017,10 @@
_ = 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
}
@@ -1824,7 +2172,14 @@
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
@@ -1834,6 +2189,7 @@
store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
@@ -1845,10 +2201,21 @@
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 {
@@ -1859,6 +2226,7 @@
store.appTags[path] = tags
}
}
+ _ = TagDatabase.normalizeContainerAppOrder(in: &store)
TagDatabase.saveUserCategorySchemeMutation(
store,
previous: previousStore,
diff --git a/src/Apptag/Info.plist b/src/Apptag/Info.plist
index 63cf046..9c81ac0 100644
--- a/src/Apptag/Info.plist
+++ b/src/Apptag/Info.plist
@@ -19,9 +19,9 @@
<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>
diff --git a/src/Apptag/SmartCategorization/SmartStartService.swift b/src/Apptag/SmartCategorization/SmartStartService.swift
index 75a9474..d377f99 100644
--- a/src/Apptag/SmartCategorization/SmartStartService.swift
+++ b/src/Apptag/SmartCategorization/SmartStartService.swift
@@ -358,6 +358,7 @@
store.tags = [:]
store.appTags = [:]
store.tagOrder = []
+ store.containerAppOrder = [:]
store.disabledSystemCategoryIDs = []
}
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 8563237..9481667 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,14 @@
# 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 面板按原型拆分为两个独立边框区域:“布局初始化”和“自定义布局”分开展示,避免重置初始化、恢复、导入导出混在同一块区域里
diff --git a/src/Scripts/app_ordering_data_qa.sh b/src/Scripts/app_ordering_data_qa.sh
new file mode 100755
index 0000000..a727ed6
--- /dev/null
+++ b/src/Scripts/app_ordering_data_qa.sh
@@ -0,0 +1,284 @@
+#!/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
diff --git a/src/Scripts/window_logic_qa.sh b/src/Scripts/window_logic_qa.sh
index d30af67..74c5350 100755
--- a/src/Scripts/window_logic_qa.sh
+++ b/src/Scripts/window_logic_qa.sh
@@ -324,9 +324,8 @@
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
@@ -344,9 +343,54 @@
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() {
@@ -1076,6 +1120,20 @@
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
@@ -1296,8 +1354,7 @@
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
@@ -1353,11 +1410,9 @@
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
@@ -1365,13 +1420,11 @@
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
diff --git a/src/TODO.md b/src/TODO.md
index 16d53c8..9563d89 100644
--- a/src/TODO.md
+++ b/src/TODO.md
@@ -13,11 +13,6 @@
## 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。
@@ -28,6 +23,13 @@
## 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)`。
--
Gitblit v1.9.3