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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View File

@@ -0,0 +1,192 @@
import APIClient
import Foundation
/// T-iOS-26 · Projects web v0.6
/// public/projects.tsfilterProjects / 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-269running = `!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 filterProjectsname/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 }
}