feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
This commit is contained in:
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
192
ios/App/WebTerm/ViewModels/ProjectGrouping.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user