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
165 lines
6.2 KiB
Swift
165 lines
6.2 KiB
Swift
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
|
||
}
|