import APIClient import Foundation /// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的 /// public/projects.ts(filterProjects / sortProjects / groupProjects / /// displayLabel),使手机与网页看到同一套分区。 /// /// 关键契约:**组 key 与 web 逐字节一致**(namespace 首见大小写、哨兵 /// `" active"` / `" other"`),因为折叠状态以组 key 存进跨端共享的 /// `/prefs.collapsed` —— key 漂移 = 两端互相丢折叠状态。 /// label 则是本端 UI 文案(哨兵组中文;namespace 组 = key 本身)。 /// /// Active 置顶(任务要求 assert reality):`ProjectSessionRef.exited` 字段 /// 实测存在(src/types.ts:262-269),running = 任一会话 `!exited` —— 与 web /// `hasRunningSession` 同一判据;running 项目**复制**进置顶组,原组保留。 enum ProjectGrouping { /// 哨兵组 key(带空格前缀,不可能与真实 `First.Second` namespace 撞车, /// 镜像 public/projects.ts:83-84)。 static let activeGroupKey = " active" static let otherGroupKey = " other" /// namespace 至少要这么多成员才配得上独立分区(MIN_GROUP_SIZE)。 static let minGroupSize = 2 // MARK: - filter(镜像 filterProjects:name/path 子串,大小写不敏感) static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return projects } let lower = trimmed.lowercased() return projects.filter { $0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower) } } // MARK: - sort(镜像 sortProjects:收藏优先 → lastActiveMs 降序;显式稳定) /// JS 的 `Array.sort` 是稳定的,web 靠它在平局时保持服务器顺序;Swift 的 /// `sorted` 未承诺稳定 → 用输入下标做最终决胜键,行为逐字节对齐。 static func sort(_ projects: [ProjectInfo], favourites: Set) -> [ProjectInfo] { projects.enumerated().sorted { a, b in let aFav = favourites.contains(a.element.path) let bFav = favourites.contains(b.element.path) if aFav != bFav { return aFav } let aActive = a.element.lastActiveMs ?? 0 let bActive = b.element.lastActiveMs ?? 0 if aActive != bActive { return aActive > bActive } return a.offset < b.offset }.map(\.element) } // MARK: - group(镜像 groupProjects) static func group(_ projects: [ProjectInfo], favourites: Set) -> [ProjectGroup] { let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites) // 分组一无所获 → 单一 flat 组(无 chrome 的平铺网格回退)。 guard !namespaceGroups.isEmpty else { return [makeGroup( key: otherGroupKey, label: ProjectsCopy.allGroupLabel, kind: .flat, items: projects, favourites: favourites )] } var groups: [ProjectGroup] = [] let active = projects.filter(hasRunningSession) if !active.isEmpty { groups.append(makeGroup( key: activeGroupKey, label: ProjectsCopy.activeGroupLabel, kind: .active, items: active, favourites: favourites )) } groups.append(contentsOf: orderedByRecency(namespaceGroups)) if !other.isEmpty { groups.append(makeGroup( key: otherGroupKey, label: ProjectsCopy.otherGroupLabel, kind: .other, items: other, favourites: favourites )) } return groups } /// 组内卡片名:namespace 组剥掉 `.` 前缀(大小写不敏感),免得每张 /// 卡都在喊 `Billo.Platform.`;哨兵组保留全名(镜像 displayLabel)。 static func displayLabel(name: String, groupKey: String) -> String { if groupKey == activeGroupKey || groupKey == otherGroupKey { return name } let prefix = "\(groupKey)." guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name } return String(name.dropFirst(prefix.count)) } static func hasRunningSession(_ project: ProjectInfo) -> Bool { project.sessions.contains { !$0.exited } } // MARK: - Internals /// namespace = 名字的前两个点分段(`'a.b.c'` → `'a.b'`;不足两段 → nil), /// 空段保留(镜像 JS `split('.')` 语义)。 private static func namespaceKey(_ name: String) -> String? { let segments = name.split(separator: ".", omittingEmptySubsequences: false) guard segments.count >= 2 else { return nil } return segments.prefix(2).joined(separator: ".") } /// 桶 key 小写去重、显示名取首见大小写、首见顺序稳定(镜像 JS Map 的 /// 插入序遍历)。返回 (成组的 namespace, 塌进 Other 的项目)。 private static func bucketByNamespace( _ projects: [ProjectInfo], favourites: Set ) -> (groups: [ProjectGroup], other: [ProjectInfo]) { var bucketOrder: [String] = [] var buckets: [String: (display: String, items: [ProjectInfo])] = [:] var other: [ProjectInfo] = [] for project in projects { guard let namespace = namespaceKey(project.name) else { other.append(project) continue } let lowerKey = namespace.lowercased() if var bucket = buckets[lowerKey] { bucket.items.append(project) buckets[lowerKey] = bucket } else { buckets[lowerKey] = (namespace, [project]) bucketOrder.append(lowerKey) } } var groups: [ProjectGroup] = [] for lowerKey in bucketOrder { guard let bucket = buckets[lowerKey] else { continue } if bucket.items.count < minGroupSize { other.append(contentsOf: bucket.items) } else { groups.append(makeGroup( key: bucket.display, label: bucket.display, kind: .namespace, items: bucket.items, favourites: favourites )) } } return (groups, other) } /// namespace 分区按组内最新活跃时间降序,平局按 label 升序(镜像 /// groupProjects 的 sort)。 private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] { groups.sorted { a, b in let aMax = maxLastActive(a.projects) let bMax = maxLastActive(b.projects) if aMax != bMax { return aMax > bMax } return a.label.localizedCompare(b.label) == .orderedAscending } } private static func maxLastActive(_ items: [ProjectInfo]) -> Int { items.reduce(0) { max($0, $1.lastActiveMs ?? 0) } } private static func makeGroup( key: String, label: String, kind: ProjectGroupKind, items: [ProjectInfo], favourites: Set ) -> ProjectGroup { ProjectGroup( key: key, label: label, kind: kind, projects: sort(items, favourites: favourites), activeCount: items.filter(hasRunningSession).count ) } } /// 分区种类(镜像 web `ProjectGroupKind`)。 enum ProjectGroupKind: Equatable, Sendable { case active case namespace case other case flat } /// 一个可折叠分区的不可变快照(镜像 web `ProjectGroup`)。 struct ProjectGroup: Equatable, Identifiable, Sendable { /// 折叠状态的持久化 key —— 必须与 web 逐字节一致(/prefs 跨端共享)。 let key: String let label: String let kind: ProjectGroupKind /// 已排序(收藏优先 → 活跃降序)。 let projects: [ProjectInfo] /// 有运行中会话的项目数(折叠的分区绝不悄悄埋掉活跃会话)。 let activeCount: Int var id: String { key } /// Active now 永远展开(它就是重点);flat 无 chrome。 var isCollapsible: Bool { kind == .namespace || kind == .other } }