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 }