edit | blame | history | raw

7.9.0 技术设计方案:容器内 App 手动排序

1. 目标

在现有 TagLauncher App Grid 中支持同一容器内 App 拖动排序,并将顺序作为“分类与布局方案”的一部分持久化。

2. 当前架构现状

  • AppInfo 不包含用户顺序字段,定义在 src/Apptag/DataLayer.swift
  • TagDatabase.Store 当前有 appTagstagOrder,没有容器内 App 顺序字段。
  • AppIndexer.group() 负责按 tag 分组,组内 App 顺序来自扫描结果 append 顺序。
  • ContentView.makeDisplayGroups() 将分组名本地化后传给 App Grid。
  • AppGridCollectionViewNSViewRepresentable + AppKit 自绘 grid。
  • AppGridGroupCardViewgroup.apps.indices 布局 icon。
  • AppGridIconNSView 当前长按拖动表示跨 tag 移动/复制,不包含目标插入 index。

3. 设计原则

  • 顺序是数据,不是临时 UI 状态。
  • 容器 ID 必须稳定,不能依赖本地化显示名。
  • 同容器排序不改变 appTags
  • 旧拖拽语义优先保护,不为排序重写跨容器拖动。
  • 第一版以 path 作为 App 顺序身份,降低实现复杂度。
  • 不存像素坐标、row/column、IndexPath,因为布局会随窗口尺寸、displayMode、iconSize 改变。

4. 数据模型

4.1 Store 新字段

建议在 TagDatabase.Store 增加:

var containerAppOrder: [String: [String]] = [:]

含义:

  • key:稳定容器 ID。
  • value:该容器内 app path 的完整或部分顺序。

旧数据兼容:

containerAppOrder = try container.decodeIfPresent(
    [String: [String]].self,
    forKey: .containerAppOrder
) ?? [:]

4.2 容器 ID

建议新增 helper,统一生成容器 ID:

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)"
    }
}

第一版可以先支持普通 tag 和特殊容器,系统分类 ID 映射按现有 TagDef.systemCategoryID 补齐。

4.3 TagGroup 扩展

当前 TagGroup.id 使用 name。建议增加:

struct TagGroup: Identifiable {
    var id: String { containerID }
    let containerID: String
    let name: String
    let apps: [AppInfo]
}

这样 UI 显示名可以本地化,但排序和滚动定位使用稳定 ID。

兼容注意:

  • 现有按 group.name 判断特殊组的逻辑要逐步改为 container ID。
  • 滚动目标如果仍传显示名,需增加兼容查找。

5. 排序算法

输入:

  • 某个容器原始 apps。
  • containerAppOrder[containerID]

输出:

  • 已命中顺序的 App 先按记录顺序排列。
  • 未命中记录的新 App 追加到后面,按现有默认名称排序。
  • 顺序字段中不存在的 path 忽略,可在保存或 reconcile 时清理。

伪代码:

func applyAppOrder(apps: [AppInfo], order: [String]) -> [AppInfo] {
    let byPath = Dictionary(uniqueKeysWithValues: apps.map { ($0.path.path, $0) })
    let ordered = order.compactMap { byPath[$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
}

6. 写入 API

建议新增:

extension TagEditor {
    static func reorderApps(inContainer containerID: String, orderedPaths: [String])
}

写入规则:

  • 只保存当前容器内可见 App 的完整 path 顺序。
  • 去重。
  • 过滤空 path。
  • 如结果等于当前有效顺序则不写。
  • TagDatabase.saveUserCategorySchemeMutation(reason: "reorder-apps")

7. 分类方案与导入导出

必须更新:

  • TagDatabase.Store.CodingKeys:加入 containerAppOrder
  • CategorySchemeFingerprint:加入规范化后的 containerAppOrder
  • exportTo / importFrom:自动随 Store 编码,但要补 roundtrip QA。
  • restore(fromBackupAt:):恢复顺序字段。
  • resetAppTagAssignmentsToUncategorized():清理或重建顺序字段,建议清空旧普通容器顺序。
  • SmartStartService.applyDraft(replaceExistingScheme: true):清空旧顺序。
  • tag rename/delete/relocalize:迁移或清理 tag:<name> 对应顺序。
  • reconcile removed apps:清理顺序字段里的 orphan path。

8. UI 集成

8.1 回调

AppGridCollectionView 增加:

let onReorderApps: (String, [String]) -> Void

其中:

  • 第一个参数为 containerID
  • 第二个参数为排序后的 app path 列表。

ContentView 接收后调用 TagEditor.reorderApps,再 refreshApps(forceLayoutRefresh: true) 或局部刷新。

8.2 Hit-test

AppGridGroupCardView 内根据鼠标位置计算 insertion index:

  • 使用当前 iconViews frame。
  • 同容器内拖动时显示插入线/占位。
  • 松手时生成新 path 顺序。
  • 拖出当前 group 或命中其他 drop target 时走旧拖拽逻辑。

8.3 拖拽 intent

现有 payload 是 path + sourceTag 字符串,不适合继续扩展复杂状态。建议内部引入结构化状态:

struct AppDragPayload {
    let path: String
    let sourceContainerID: String
    let sourceDisplayName: String
}

第一版如果为了控制改动范围暂不完全替换 AppDragCoordinator payload,也必须在同容器排序分支中独立判断 source/target 和 insertion index,避免误触发旧 drop。

8.4 状态清理

排序开始:

  • 抑制 hover bubble。
  • 结束 tag nav reorder。
  • 标记 app drag mode active。

排序结束或取消:

  • 清除插入提示。
  • 恢复 bubble 状态。
  • 清理 drag image / hover target。

9. 不改范围

  • 不改 Quick Search rank。
  • 不改 App 扫描规则。
  • 不改 SmartStart 分类匹配逻辑。
  • 不改跨容器拖动的标签归属规则。
  • 不改设置页现有数据面板结构。

10. 迁移策略

  • 不需要一次性迁移用户数据。
  • 新字段缺省为空,表示使用旧默认顺序。
  • 第一次用户在某容器排序后,只写该容器顺序。
  • 版本升级到 7.9.0 时在 changelog 说明新增布局顺序字段。

11. 开发顺序

  1. 数据层:
  • Store 字段、容器 ID、排序 helper、写入 API、fingerprint、导入导出/恢复/重置/SmartStart 处理。
  • 配套数据 QA。
  1. UI 层:
  • 同容器 insertion index hit-test。
  • 插入提示。
  • 本地顺序预览。
  1. 集成:
  • drop/end 写入。
  • refresh 后顺序保持。
  • 旧拖拽行为回归。
  1. 发布:
  • 版本号 7.9.0
  • CHANGELOG.md
  • DMG / App Store 资料按实际发布目标准备。

12. 风险与控制

风险 等级 控制
顺序未持久化导致刷新丢失 数据层先行,QA roundtrip
本地化显示名作为 key 导致语言切换错位 统一 container ID
同容器排序误触发跨标签移动 独立 intent 和 hit-test
导入导出/恢复漏字段 fingerprint + export/import QA
拖拽状态残留 统一 cancel/cleanup
新字段污染旧用户默认顺序 空字段即旧排序

13. 必跑验证

cd /Users/ar/Projects/Taglauncher/src
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

同时必须做真实鼠标手工验证,覆盖同容器排序、跨容器拖动、Option 复制、拖空白移除、macOS 14、5 种 displayMode 和 3 种标签位置。