Files
web-terminal/ios/App/WebTerm/ViewModels/DiffViewModel.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

165 lines
6.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// filestruncated
case empty(truncated: Bool)
case loaded(DiffPresentation)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// false = unstagedtrue = --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 thepath: 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-166id
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 webpublic/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
}