T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
193 lines
8.1 KiB
Swift
193 lines
8.1 KiB
Swift
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<String>) -> [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<String>) -> [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 组剥掉 `<key>.` 前缀(大小写不敏感),免得每张
|
||
/// 卡都在喊 `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<String>
|
||
) -> (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<String>
|
||
) -> 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 }
|
||
}
|