Ariver
2026-06-11 a1ade5e8426d9cccd3c59f692d6fab55e67021fa
Add 7.9 app ordering support
14 files modified
2 files added
1234 ■■■■■ changed files
docs/7.90/01-容器内App手动排序-TODO.md 41 ●●●● patch | view | raw | blame | history
docs/7.90/04-QA测试用例集.md 9 ●●●● patch | view | raw | blame | history
docs/7.90/07-实现与QA记录.md 44 ●●●●● patch | view | raw | blame | history
docs/7.90/README.md 5 ●●●●● patch | view | raw | blame | history
src/Apptag/AppDragCoordinator.swift 17 ●●●●● patch | view | raw | blame | history
src/Apptag/AppGridCollectionView.swift 265 ●●●●● patch | view | raw | blame | history
src/Apptag/AppLibraryController.swift 10 ●●●●● patch | view | raw | blame | history
src/Apptag/ApptagApp.swift 16 ●●●● patch | view | raw | blame | history
src/Apptag/ContentView.swift 42 ●●●●● patch | view | raw | blame | history
src/Apptag/DataLayer.swift 392 ●●●●● patch | view | raw | blame | history
src/Apptag/Info.plist 4 ●●●● patch | view | raw | blame | history
src/Apptag/SmartCategorization/SmartStartService.swift 1 ●●●● patch | view | raw | blame | history
src/CHANGELOG.md 9 ●●●●● patch | view | raw | blame | history
src/Scripts/app_ordering_data_qa.sh 284 ●●●●● patch | view | raw | blame | history
src/Scripts/window_logic_qa.sh 83 ●●●● patch | view | raw | blame | history
src/TODO.md 12 ●●●●● patch | view | raw | blame | history
docs/7.90/01-容器内App手动排序-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 自动化回归通过。
## 风险等级
docs/7.90/04-QA测试用例集.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. 发布阻断条件
- 顺序在刷新、重启、语言切换、导入导出、恢复上一方案任一路径丢失。
docs/7.90/07-实现与QA记录.md
New file
@@ -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 或本机构建做最终体验验收,重点看拖拽插入线是否符合预期。
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。
- 完成阶段性实现后,将验证结果继续补充到本目录,而不是散落在会话记录里。
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 {
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) {
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
            )
        )
    }
}
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
        }
    }
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 {
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,
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>
src/Apptag/SmartCategorization/SmartStartService.swift
@@ -358,6 +358,7 @@
            store.tags = [:]
            store.appTags = [:]
            store.tagOrder = []
            store.containerAppOrder = [:]
            store.disabledSystemCategoryIDs = []
        }
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 面板按原型拆分为两个独立边框区域:“布局初始化”和“自定义布局”分开展示,避免重置初始化、恢复、导入导出混在同一块区域里
src/Scripts/app_ordering_data_qa.sh
New file
@@ -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
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
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)`。