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:
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
164
ios/App/WebTerm/ViewModels/DiffViewModel.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`).
|
||||
///
|
||||
/// The fetch closure is injected —— 生产由 `forProject` 用 `DiffFetcher` 包一
|
||||
/// 层(App 装配缝,T-iOS-26 的 ProjectDetail 入口按 (endpoint, path) 构造);
|
||||
/// 测试注入 fake。服务器数据的不可信处理(宽容解码、错误分类)已在
|
||||
/// `DiffFetcher` 完成;本 VM 只区分用户可见的四种结局:
|
||||
/// - 非空 files → `.loaded`:文件头/hunk 头/行**平铺**为惰性列表行模型
|
||||
/// (巨 diff —— 服务器上限 DIFF_MAX_BYTES 2MB —— 绝不合成单个 Text 块);
|
||||
/// - `[]` → `.empty`(truncated 位透传,截断到空也要提示);
|
||||
/// - 400/非法请求 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、
|
||||
/// 其余 → `.failed(.unavailable)` —— 全部可经 `load()` 重试;
|
||||
/// - staged/unstaged 切换(`setStaged`)→ 以新范围重新 fetch,同值 no-op。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DiffViewModel {
|
||||
/// 用户可见的失败三分类(文案映射在 DiffScreen 的 DiffCopy)。
|
||||
enum Failure: Equatable {
|
||||
case pathInvalid
|
||||
case notFound
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Rendering phase — an explicit enum so the screen can never show an
|
||||
/// error and diff rows at the same time (same discipline as Timeline).
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
/// 服务器回了空 files(该范围无改动)。truncated 位仍需提示。
|
||||
case empty(truncated: Bool)
|
||||
case loaded(DiffPresentation)
|
||||
case failed(Failure)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
/// 当前范围:false = 工作区(unstaged),true = 已暂存(--staged)。
|
||||
private(set) var staged = false
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult
|
||||
|
||||
init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) {
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝:`DiffScreen(endpoint:path:http:)` 经此构造(T-iOS-26 的
|
||||
/// ProjectDetail 入口只需转手这三样,无需触碰 DiffFetcher)。
|
||||
static func forProject(
|
||||
endpoint: HostEndpoint, path: String, http: any HTTPTransport
|
||||
) -> DiffViewModel {
|
||||
let fetcher = DiffFetcher(endpoint: endpoint, http: http)
|
||||
return DiffViewModel(fetch: { staged in
|
||||
try await fetcher.fetch(path: path, staged: staged)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch and present. Also the「重试」path: callable again from `.failed`.
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let result = try await fetch(staged)
|
||||
phase = Self.presentation(for: result)
|
||||
} catch let error as DiffFetchError {
|
||||
phase = .failed(Self.failure(for: error))
|
||||
} catch {
|
||||
phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底
|
||||
}
|
||||
}
|
||||
|
||||
/// staged/unstaged 切换 → 重新 fetch;同值绝不重复请求(任务 Steps)。
|
||||
func setStaged(_ newValue: Bool) async {
|
||||
guard newValue != staged else { return }
|
||||
staged = newValue
|
||||
await load()
|
||||
}
|
||||
|
||||
// MARK: - 纯呈现归约(静态,可单测)
|
||||
|
||||
static func presentation(for result: DiffResult) -> Phase {
|
||||
guard !result.files.isEmpty else {
|
||||
return .empty(truncated: result.truncated)
|
||||
}
|
||||
return .loaded(DiffPresentation(
|
||||
rows: makeRows(files: result.files), truncated: result.truncated
|
||||
))
|
||||
}
|
||||
|
||||
/// 平铺:文件头 → (binary 占位 | hunk 头 → 行…)…,逐文件顺序保持服务器
|
||||
/// 返回顺序;binary 短路 hunks(镜像 web renderDiffFile 的 early return,
|
||||
/// public/diff.ts:163-166)。id 为稳定递增序号(行内容可重复,不能当身份)。
|
||||
static func makeRows(files: [DiffFile]) -> [DiffRow] {
|
||||
var rows: [DiffRow] = []
|
||||
for file in files {
|
||||
rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file))))
|
||||
if file.binary {
|
||||
rows.append(DiffRow(id: rows.count, kind: .binaryNotice))
|
||||
continue
|
||||
}
|
||||
for hunk in file.hunks {
|
||||
rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header)))
|
||||
for line in hunk.lines {
|
||||
rows.append(DiffRow(
|
||||
id: rows.count, kind: .line(kind: line.kind, text: line.text)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
private static func failure(for error: DiffFetchError) -> Failure {
|
||||
switch error {
|
||||
case .invalidRequest, .pathInvalid:
|
||||
return .pathInvalid
|
||||
case .projectNotFound:
|
||||
return .notFound
|
||||
case .invalidResponse, .unexpectedStatus:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename 显示 “old → new”(镜像 web,public/diff.ts:146-150),其余显示
|
||||
/// newPath。路径是服务器字节 —— 屏幕侧一律 `Text(verbatim:)`。
|
||||
private static func header(for file: DiffFile) -> DiffFileHeader {
|
||||
let pathLabel = file.status == .renamed && file.oldPath != file.newPath
|
||||
? "\(file.oldPath) → \(file.newPath)"
|
||||
: file.newPath
|
||||
return DiffFileHeader(
|
||||
pathLabel: pathLabel, added: file.added,
|
||||
removed: file.removed, status: file.status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 行模型(惰性列表的最小呈现单元)
|
||||
|
||||
/// Display-ready flattened diff(不可变快照)。
|
||||
struct DiffPresentation: Equatable {
|
||||
let rows: [DiffRow]
|
||||
let truncated: Bool
|
||||
}
|
||||
|
||||
/// One lazy-list row. `id` = 平铺序号(稳定、唯一——文本内容可重复)。
|
||||
struct DiffRow: Equatable, Identifiable {
|
||||
enum Kind: Equatable {
|
||||
case fileHeader(DiffFileHeader)
|
||||
case binaryNotice
|
||||
case hunkHeader(String)
|
||||
case line(kind: DiffLineKind, text: String)
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let kind: Kind
|
||||
}
|
||||
|
||||
/// File-header row payload(路径标签已含 rename 箭头)。
|
||||
struct DiffFileHeader: Equatable {
|
||||
let pathLabel: String
|
||||
let added: Int
|
||||
let removed: Int
|
||||
let status: DiffFileStatus
|
||||
}
|
||||
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
76
ios/App/WebTerm/ViewModels/ProjectDetailViewModel.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-26 · 项目详情状态(`GET /projects/detail?path=` → phase 状态机,
|
||||
/// 与 DiffViewModel/TimelineViewModel 同一纪律)。
|
||||
///
|
||||
/// fetch 闭包注入 —— 生产由 `forHost` 包 `APIClient.projectDetail(path:)`
|
||||
/// (builder 百分号编码、400/404/500 `{error}` → 类型化错误均在 T-iOS-38
|
||||
/// 完成并已测);测试注入 fake。本 VM 只归约用户可见的三种结局:
|
||||
/// - 成功 → `.loaded(ProjectDetail)`(sessions/worktrees/hasClaudeMd 透传);
|
||||
/// - 400 → `.failed(.pathInvalid)`、404 → `.failed(.notFound)`、
|
||||
/// 500/解码/传输 → `.failed(.unavailable)` —— 全部可经 `load()` 重试。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectDetailViewModel {
|
||||
/// 用户可见的失败三分类(文案映射在 ProjectDetailScreen)。
|
||||
enum Failure: Equatable {
|
||||
case pathInvalid
|
||||
case notFound
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
case loaded(ProjectDetail)
|
||||
case failed(Failure)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
/// 详情/diff 的目标项目路径(来自列表行 —— 服务器数据;只透传给
|
||||
/// builder,绝不本地拼 URL)。
|
||||
let path: String
|
||||
|
||||
@ObservationIgnored
|
||||
private let fetch: @Sendable () async throws -> ProjectDetail
|
||||
|
||||
init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) {
|
||||
self.path = path
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// 生产装配缝(ProjectsViewModel.makeDetailViewModel 经此构造)。
|
||||
static func forHost(
|
||||
endpoint: HostEndpoint, http: any HTTPTransport, path: String
|
||||
) -> ProjectDetailViewModel {
|
||||
let client = APIClient(endpoint: endpoint, http: http)
|
||||
return ProjectDetailViewModel(path: path, fetch: {
|
||||
try await client.projectDetail(path: path)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch 并呈现。也是「重试」路径:`.failed` 后可再次调用。
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
phase = .loaded(try await fetch())
|
||||
} catch let error as APIClientError {
|
||||
phase = .failed(Self.failure(for: error))
|
||||
} catch {
|
||||
phase = .failed(.unavailable) // 传输层等其余错误:可重试兜底
|
||||
}
|
||||
}
|
||||
|
||||
private static func failure(for error: APIClientError) -> Failure {
|
||||
switch error {
|
||||
case .projectPathInvalid, .invalidRequest:
|
||||
return .pathInvalid
|
||||
case .projectNotFound:
|
||||
return .notFound
|
||||
default:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
236
ios/App/WebTerm/ViewModels/ProjectsViewModel.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-26 · Projects 列表状态(镜像 web v0.6 projects 面板的数据面):
|
||||
/// `GET /projects` 的分组网格 + `GET/PUT /prefs` 的跨端收藏/折叠往返 +
|
||||
/// "在此仓库开新会话" 的导航信号。
|
||||
///
|
||||
/// Documented decisions:
|
||||
/// - **prefs 是 clobber 敏感面**:`PUT /prefs` 服务器侧整体替换 blob
|
||||
/// (src/server.ts:286-288),所以一律经 `UiPrefs` 的 unknown-key-preserving
|
||||
/// API 改写(web/未来服务器写入的未知顶层键原样带回);prefs GET 失败时
|
||||
/// toggle 只改本地、**绝不 PUT**(空底盘上写 = 清掉服务器收藏)。
|
||||
/// - **PUT 成功采纳服务器 echo 为新真相**(服务器会 sanitize,本地状态与
|
||||
/// 服务器逐字节对齐);失败保留本地改动 + 显式文案,下次 toggle 自然重试。
|
||||
/// - **prefs 只在首次 load 拉一次**(镜像 web mountProjects.init:刷新节奏
|
||||
/// 只重取项目,绝不用旧服务器值clobber本地未同步的编辑)。
|
||||
/// - 列表数据是不可信服务器输入:解码宽容(APIClient 已做),路径在铸造
|
||||
/// OpenRequest 前再过 `Validation.isAbsoluteCwd`(deep-link 同款纪律)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectsViewModel {
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var projects: [ProjectInfo] = []
|
||||
private(set) var hasLoadedOnce = false
|
||||
/// 最近一次项目刷新失败(旧列表保留 —— 一次丢包不清屏)。
|
||||
private(set) var fetchErrorMessage: String?
|
||||
/// prefs 加载失败:收藏/折叠只在本地生效、不回写(防 clobber)。
|
||||
private(set) var prefsErrorMessage: String?
|
||||
/// 最近一次 PUT /prefs 失败(本地改动已保留)。
|
||||
private(set) var prefsSyncErrorMessage: String?
|
||||
/// "在此仓库开新会话" 被拒(非法路径)。
|
||||
private(set) var openErrorMessage: String?
|
||||
/// 收藏的项目路径(保序:新收藏追加尾部)。
|
||||
private(set) var favourites: [String] = []
|
||||
/// 组 key → 已折叠(只存 true;展开是默认态 —— 与 web/服务器一致)。
|
||||
private(set) var collapsedGroups: [String: Bool] = [:]
|
||||
/// 导航信号(T-iOS-26):每次请求新 id,重复点按也能触发 onChange。
|
||||
private(set) var openRequest: ProjectOpenRequest?
|
||||
/// 搜索框绑定(.searchable)。
|
||||
var searchText = ""
|
||||
|
||||
// MARK: - Dependencies & internal state
|
||||
|
||||
let host: HostRegistry.Host
|
||||
@ObservationIgnored let http: any HTTPTransport
|
||||
/// 最近一次已知的完整 prefs blob(含未知键)。nil = 从未成功加载 →
|
||||
/// 绝不 PUT。
|
||||
@ObservationIgnored private var prefsBase: UiPrefs?
|
||||
|
||||
private var client: APIClient {
|
||||
APIClient(endpoint: host.endpoint, http: http)
|
||||
}
|
||||
|
||||
init(host: HostRegistry.Host, http: any HTTPTransport) {
|
||||
self.host = host
|
||||
self.http = http
|
||||
}
|
||||
|
||||
// MARK: - Derived view state
|
||||
|
||||
/// 分组快照:搜索过滤 → web 同款分组(收藏优先排序在组内完成)。
|
||||
var groups: [ProjectGroup] {
|
||||
ProjectGrouping.group(
|
||||
ProjectGrouping.filter(projects, query: searchText),
|
||||
favourites: Set(favourites)
|
||||
)
|
||||
}
|
||||
|
||||
var isSearching: Bool {
|
||||
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// 搜索中强制全展开(结果绝不藏在折叠 caret 后,镜像 web renderGrid)。
|
||||
func isCollapsed(_ group: ProjectGroup) -> Bool {
|
||||
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
|
||||
}
|
||||
|
||||
func isFavourite(_ path: String) -> Bool {
|
||||
favourites.contains(path)
|
||||
}
|
||||
|
||||
/// 空态文案:无项目 vs 无搜索结果(镜像 web renderGrid 的两种 msg)。
|
||||
var emptyStateMessage: String? {
|
||||
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
|
||||
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
|
||||
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
|
||||
return ProjectsCopy.emptyNoMatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Load / refresh
|
||||
|
||||
/// 首次进入:prefs(一次)+ 项目。prefs 之后留在内存(镜像 web init)。
|
||||
func load() async {
|
||||
await loadPrefsIfNeeded()
|
||||
await refresh()
|
||||
}
|
||||
|
||||
/// 只重取项目(下拉刷新与重试共用)。失败保留旧列表 + 显式文案。
|
||||
func refresh() async {
|
||||
do {
|
||||
projects = try await client.projects()
|
||||
fetchErrorMessage = nil
|
||||
hasLoadedOnce = true
|
||||
} catch {
|
||||
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPrefsIfNeeded() async {
|
||||
guard prefsBase == nil else { return }
|
||||
do {
|
||||
let prefs = try await client.prefs()
|
||||
adopt(prefs)
|
||||
prefsErrorMessage = nil
|
||||
} catch {
|
||||
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favourites / collapse(跨端 prefs 往返)
|
||||
|
||||
func toggleFavourite(path: String) async {
|
||||
favourites = favourites.contains(path)
|
||||
? favourites.filter { $0 != path }
|
||||
: favourites + [path]
|
||||
await persistPrefs()
|
||||
}
|
||||
|
||||
func toggleCollapsed(key: String) async {
|
||||
if collapsedGroups[key] == true {
|
||||
collapsedGroups = collapsedGroups.filter { $0.key != key }
|
||||
} else {
|
||||
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
|
||||
}
|
||||
await persistPrefs()
|
||||
}
|
||||
|
||||
/// 以最近已知的完整 blob 为底盘改写两个已知键(未知键原样带回),PUT
|
||||
/// 后采纳服务器 echo。无底盘(prefs 从未加载成功)→ 本地生效、不回写。
|
||||
private func persistPrefs() async {
|
||||
guard let base = prefsBase else { return }
|
||||
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
|
||||
prefsBase = next // 乐观:连续 toggle 在同一底盘上叠加
|
||||
do {
|
||||
let echoed = try await client.putPrefs(next)
|
||||
adopt(echoed)
|
||||
prefsSyncErrorMessage = nil
|
||||
} catch {
|
||||
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func adopt(_ prefs: UiPrefs) {
|
||||
prefsBase = prefs
|
||||
favourites = prefs.favourites
|
||||
collapsedGroups = prefs.collapsed
|
||||
}
|
||||
|
||||
// MARK: - 在此仓库开新会话(T-iOS-26 的核心动作)
|
||||
|
||||
/// `attach(null, cwd)` + attach 后注入 `claude\r`(帧序由 engine 的
|
||||
/// attach-first 队列保证)。路径是服务器数据 → 不可信,铸造前先验证。
|
||||
func requestOpenClaude(cwd: String) {
|
||||
guard Validation.isAbsoluteCwd(cwd) else {
|
||||
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
|
||||
return
|
||||
}
|
||||
openErrorMessage = nil
|
||||
openRequest = ProjectOpenRequest(
|
||||
id: UUID(), host: host, cwd: cwd,
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Detail assembly(详情/差异屏的装配缝)
|
||||
|
||||
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
|
||||
.forHost(endpoint: host.endpoint, http: http, path: path)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func errorDetail(_ error: any Error) -> String {
|
||||
(error as? APIClientError)?.message ?? error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// "在此仓库开新会话" 的导航信号 —— `SessionListViewModel.OpenRequest` 的
|
||||
/// cwd+bootstrap 平行变体(该类型属 T-iOS-13 文件,不越界扩展;由
|
||||
/// AppCoordinator.openProject 消费)。
|
||||
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
|
||||
let id: UUID
|
||||
let host: HostRegistry.Host
|
||||
/// 新会话的工作目录(已验证为绝对路径)。
|
||||
let cwd: String
|
||||
/// attach 后注入的首条输入(nil = 只开 shell)。
|
||||
let bootstrapInput: String?
|
||||
}
|
||||
|
||||
/// 项目内启动命令(镜像 public/tabs.ts:679 `openProject` 的默认 cmd)。
|
||||
enum ProjectLaunch {
|
||||
/// Enter 是 `\r`(0x0D)不是 `\n` —— 合成输入的经典坑(CLAUDE.md Gotchas)。
|
||||
static let claudeBootstrapInput = "claude\r"
|
||||
}
|
||||
|
||||
/// 用户可见文案(中文具名常量,plan §4)。
|
||||
enum ProjectsCopy {
|
||||
static let title = "项目"
|
||||
static let searchPrompt = "筛选项目…"
|
||||
static let activeGroupLabel = "活跃中"
|
||||
static let otherGroupLabel = "其他"
|
||||
static let allGroupLabel = "全部项目"
|
||||
static let dirtyBadge = "未提交"
|
||||
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
|
||||
static let emptyNoMatch = "没有匹配的项目。"
|
||||
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
|
||||
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
|
||||
|
||||
static func fetchFailed(_ detail: String) -> String {
|
||||
"刷新项目列表失败:\(detail)"
|
||||
}
|
||||
|
||||
static func prefsSyncFailed(_ detail: String) -> String {
|
||||
"收藏/折叠状态同步失败:\(detail)"
|
||||
}
|
||||
|
||||
static func activeCountBadge(_ count: Int) -> String {
|
||||
"● \(count) 活跃"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Observation
|
||||
import SessionCore
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
|
||||
@@ -56,6 +57,12 @@ final class SessionListViewModel {
|
||||
let info: LiveSessionInfo
|
||||
let isPendingApproval: Bool
|
||||
let telemetry: TelemetryChips.Model?
|
||||
/// Sanitized OSC title (T-iOS-23) — nil = no title, row falls back to
|
||||
/// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
|
||||
let title: String?
|
||||
/// `lastOutputAt` strictly newer than the local last-seen watermark
|
||||
/// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
|
||||
let isUnread: Bool
|
||||
|
||||
var id: UUID { info.id }
|
||||
var indicator: StatusIndicator {
|
||||
@@ -105,10 +112,19 @@ final class SessionListViewModel {
|
||||
@ObservationIgnored private let clock: any Clock<Duration>
|
||||
/// Injected time source for telemetry staleness (ms since epoch).
|
||||
@ObservationIgnored private let nowMs: @Sendable () -> Int
|
||||
@ObservationIgnored private let unreadStore: any UnreadWatermarkStore
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
|
||||
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
|
||||
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
|
||||
/// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
|
||||
/// NOT pruned to the active host's fetch — other hosts' watermarks must
|
||||
/// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
|
||||
@ObservationIgnored private var unreadLedger: UnreadLedger
|
||||
/// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only — replay
|
||||
/// re-emits the OSC sequence on next attach, persistence would only keep
|
||||
/// stale titles. Other hosts' entries never match a visible row.
|
||||
@ObservationIgnored private var sessionTitles: [UUID: String] = [:]
|
||||
@ObservationIgnored private var hasAttemptedHostLoad = false
|
||||
@ObservationIgnored private var hasLoadedOnce = false
|
||||
|
||||
@@ -120,12 +136,15 @@ final class SessionListViewModel {
|
||||
hostStore: any HostStore,
|
||||
http: any HTTPTransport,
|
||||
clock: any Clock<Duration>,
|
||||
unreadStore: any UnreadWatermarkStore,
|
||||
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
|
||||
) {
|
||||
self.hostStore = hostStore
|
||||
self.http = http
|
||||
self.clock = clock
|
||||
self.unreadStore = unreadStore
|
||||
self.nowMs = nowMs
|
||||
unreadLedger = UnreadLedger(watermarks: unreadStore.load())
|
||||
}
|
||||
|
||||
// MARK: - Visibility lifecycle (poll task owner)
|
||||
@@ -230,6 +249,32 @@ final class SessionListViewModel {
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)
|
||||
|
||||
/// The user just looked at (or is about to look at) this session: stamp
|
||||
/// the last-seen watermark NOW and persist it. Called on row tap
|
||||
/// (`openSession`) and by the coordinator when a terminal closes — output
|
||||
/// that streamed while the user was watching must not relight the dot.
|
||||
func markSeen(sessionId: UUID) {
|
||||
unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
|
||||
unreadStore.save(unreadLedger.watermarks)
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)
|
||||
|
||||
/// Surface a terminal-reported OSC title on the session's list row.
|
||||
/// `title` is UNTRUSTED (host/attacker-controlled OSC payload) — sanitized
|
||||
/// again at THIS boundary regardless of upstream (sanitize is idempotent).
|
||||
/// Empty after sanitisation clears the entry (web `title.trim() || null`).
|
||||
func setSessionTitle(sessionId: UUID, title: String) {
|
||||
let sanitized = TitleSanitizer.sanitize(title)
|
||||
sessionTitles = sanitized.isEmpty
|
||||
? sessionTitles.filter { $0.key != sessionId }
|
||||
: sessionTitles.merging([sessionId: sanitized]) { _, new in new }
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
// MARK: - Swipe-to-kill (optimistic + rollback)
|
||||
|
||||
func kill(sessionId: UUID) async {
|
||||
@@ -258,8 +303,11 @@ final class SessionListViewModel {
|
||||
}
|
||||
|
||||
/// Open a listed session. Unknown ids are refused at the boundary.
|
||||
/// Tapping = seeing: the unread watermark is stamped immediately (the
|
||||
/// terminal replays everything anyway; the dot must not outlive the tap).
|
||||
func openSession(id: UUID) {
|
||||
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
|
||||
markSeen(sessionId: id)
|
||||
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
|
||||
}
|
||||
|
||||
@@ -272,7 +320,11 @@ final class SessionListViewModel {
|
||||
SessionRow(
|
||||
info: info,
|
||||
isPendingApproval: pendingSessionIds.contains(info.id),
|
||||
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now)
|
||||
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
|
||||
title: sessionTitles[info.id],
|
||||
isUnread: unreadLedger.isUnread(
|
||||
sessionId: info.id, lastOutputAt: info.lastOutputAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ final class TerminalViewModel {
|
||||
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
||||
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
|
||||
private(set) var sessionId: UUID?
|
||||
/// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
|
||||
/// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
|
||||
/// boundary; nil = no title (empty after sanitisation).
|
||||
private(set) var terminalTitle: String?
|
||||
|
||||
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
|
||||
/// is NOT gated here — the engine owns terminal-state dropping.
|
||||
@@ -121,6 +125,17 @@ final class TerminalViewModel {
|
||||
/// Test tap, called after each event is applied (state already coherent).
|
||||
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
|
||||
|
||||
// MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)
|
||||
|
||||
/// List-side registry hook: fires with the ADOPTED sessionId and the
|
||||
/// SANITIZED title ("" = title cleared — the registry drops the entry).
|
||||
/// Titles arriving before adoption are held and forwarded once on
|
||||
/// `.adopted` (defensive — `attached` always precedes output on the wire).
|
||||
@ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
|
||||
/// Latest sanitized title not yet delivered to `onTitleChanged` because
|
||||
/// no sessionId was known at the time. nil = nothing held.
|
||||
@ObservationIgnored private var heldTitleForward: String?
|
||||
|
||||
private struct CountWaiter {
|
||||
let target: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
@@ -194,6 +209,19 @@ final class TerminalViewModel {
|
||||
enqueueSend(.input(data: data))
|
||||
}
|
||||
|
||||
/// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
|
||||
/// Sanitize FIRST — the raw string is untrusted terminal output — then
|
||||
/// surface locally and forward to the list registry (T-iOS-23).
|
||||
func setTerminalTitle(_ raw: String) {
|
||||
let sanitized = TitleSanitizer.sanitize(raw)
|
||||
terminalTitle = sanitized.isEmpty ? nil : sanitized
|
||||
guard let sessionId else {
|
||||
heldTitleForward = sanitized
|
||||
return
|
||||
}
|
||||
onTitleChanged?(sessionId, sanitized)
|
||||
}
|
||||
|
||||
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded —
|
||||
/// the engine validates bounds and owns terminal-state dropping. Valid
|
||||
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
|
||||
@@ -214,6 +242,10 @@ final class TerminalViewModel {
|
||||
applyConnection(state)
|
||||
case .adopted(let id):
|
||||
sessionId = id // ALWAYS adopt the server-issued id
|
||||
if let held = heldTitleForward {
|
||||
heldTitleForward = nil
|
||||
onTitleChanged?(id, held)
|
||||
}
|
||||
case .output(let data):
|
||||
deliverOutput(data)
|
||||
case .exited(let code, let reason):
|
||||
|
||||
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
81
ios/App/WebTerm/ViewModels/TimelineViewModel.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
|
||||
///
|
||||
/// One VM per presentation (the container view builds a fresh one on each
|
||||
/// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`.
|
||||
/// The fetch closure is injected — production wraps `APIClient.events` via
|
||||
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
|
||||
///
|
||||
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
|
||||
/// (malformed / unknown-class entries dropped, non-array → `[]`) already
|
||||
/// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM
|
||||
/// only distinguishes the three USER-visible outcomes:
|
||||
/// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and
|
||||
/// for timeline capture disabled (src/server.ts:589-591); NEVER an error;
|
||||
/// - thrown fetch → `.failed` — explicit, retryable (`load()` again);
|
||||
/// - events → `.loaded`, ordered exactly like the web panel.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TimelineViewModel: Identifiable {
|
||||
/// Rendering phase — an explicit enum so the sheet can never show an
|
||||
/// error and rows at the same time.
|
||||
enum Phase: Equatable {
|
||||
case loading
|
||||
/// Display-ready rows: capped + newest-first (web parity, see
|
||||
/// `presentation(for:)`).
|
||||
case loaded([TimelineEvent])
|
||||
/// Server returned `[]` (no activity OR timeline disabled host-side).
|
||||
case empty
|
||||
/// Fetch failed — retryable via `load()`.
|
||||
case failed
|
||||
}
|
||||
|
||||
/// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
|
||||
static let maxEvents = 50
|
||||
|
||||
/// `.sheet(item:)` identity — a fresh VM per presentation.
|
||||
nonisolated let id = UUID()
|
||||
|
||||
private(set) var phase: Phase = .loading
|
||||
|
||||
@ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]
|
||||
|
||||
init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
|
||||
self.fetch = fetch
|
||||
}
|
||||
|
||||
/// Assembly seam for the digest「展开」entry point: no adopted sessionId
|
||||
/// yet → no sheet (defensive — a digest only arrives after `attached`, so
|
||||
/// the id is normally known). Otherwise the id is passed to `source`
|
||||
/// verbatim on every load.
|
||||
static func forSession(
|
||||
_ sessionId: UUID?,
|
||||
source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
|
||||
) -> TimelineViewModel? {
|
||||
guard let sessionId else { return nil }
|
||||
return TimelineViewModel(fetch: { try await source(sessionId) })
|
||||
}
|
||||
|
||||
/// Fetch and present. Also the「重试」path: callable again from `.failed`.
|
||||
func load() async {
|
||||
phase = .loading
|
||||
do {
|
||||
let events = try await fetch()
|
||||
phase = Self.presentation(for: events)
|
||||
} catch {
|
||||
phase = .failed // user-facing copy lives in TimelineSheet
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure presentation reducer, mirroring the web panel's render() pipeline
|
||||
/// line for line (public/timeline.ts:174-189): the server returns
|
||||
/// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet
|
||||
/// shows the same capped slice newest-first as the web timeline panel.
|
||||
static func presentation(for events: [TimelineEvent]) -> Phase {
|
||||
guard !events.isEmpty else { return .empty }
|
||||
return .loaded(Array(events.prefix(maxEvents).reversed()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user